rigortype 0.4.6 → 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 +50 -3
- data/docs/handbook/appendix-phpstan.md +4 -4
- data/docs/manual/02-cli-reference.md +152 -26
- data/docs/manual/03-configuration.md +36 -3
- data/docs/manual/06-baseline.md +6 -4
- data/docs/manual/09-editor-integration.md +13 -2
- data/docs/manual/10-mcp-server.md +38 -2
- data/docs/manual/12-caching.md +4 -3
- data/docs/manual/plugins/rigor-actionpack.md +19 -11
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +20 -1
- data/lib/rigor/analysis/baseline.rb +24 -27
- 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 +85 -13
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +0 -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/rebinding_block_callees.rb +3 -3
- data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +1 -2
- 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 +2 -2
- data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +1 -1
- data/lib/rigor/analysis/check_rules.rb +124 -44
- data/lib/rigor/analysis/crash_signature.rb +2 -2
- data/lib/rigor/analysis/dependency_recorder.rb +4 -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 +93 -6
- data/lib/rigor/analysis/fact_store.rb +3 -3
- 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 +27 -16
- data/lib/rigor/analysis/rule_catalog.rb +2 -2
- data/lib/rigor/analysis/run_stats.rb +2 -2
- data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +2 -2
- data/lib/rigor/analysis/runner/declaration_position.rb +1 -1
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +193 -93
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +1 -1
- data/lib/rigor/analysis/runner/pool_coordinator.rb +147 -323
- data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -3
- 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/worker_payload.rb +36 -0
- data/lib/rigor/analysis/runner.rb +77 -71
- 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 +24 -31
- 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 +31 -12
- 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 +108 -102
- 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 +25 -20
- 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 +12 -8
- data/lib/rigor/cli/lsp_command.rb +250 -23
- data/lib/rigor/cli/mcp_command.rb +12 -7
- 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 +40 -1
- data/lib/rigor/cli/plugin_command.rb +93 -46
- data/lib/rigor/cli/plugins_command.rb +17 -18
- data/lib/rigor/cli/plugins_renderer.rb +10 -6
- 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 +21 -15
- data/lib/rigor/cli/sig_gen_command.rb +84 -27
- 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 +16 -11
- data/lib/rigor/cli/type_of_project_scope.rb +1 -4
- 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 +1 -1
- data/lib/rigor/configuration/severity_profile.rb +5 -5
- data/lib/rigor/configuration.rb +72 -30
- 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 +480 -67
- data/lib/rigor/environment/reflection.rb +7 -7
- data/lib/rigor/environment/required_features.rb +0 -3
- data/lib/rigor/environment/ruby_library_roots.rb +1 -1
- data/lib/rigor/environment/vendored_signature_plan.rb +153 -0
- data/lib/rigor/environment.rb +41 -10
- 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 +88 -77
- 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 +39 -7
- 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 +46 -4
- data/lib/rigor/inference/definer_resolution.rb +4 -2
- 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/expression_typer.rb +116 -49
- data/lib/rigor/inference/external_ancestor_resolution.rb +3 -4
- data/lib/rigor/inference/global_write_census.rb +76 -2
- 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 +51 -0
- data/lib/rigor/inference/key_presence_guard.rb +2 -1
- data/lib/rigor/inference/loop_value_record.rb +8 -4
- data/lib/rigor/inference/match_rebinding/frame.rb +46 -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 +3 -3
- 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 +1 -1
- 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 +9 -7
- 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 +64 -14
- data/lib/rigor/inference/module_function_state.rb +11 -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 +157 -27
- data/lib/rigor/inference/narrowing.rb +47 -33
- 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_yielder.rb +11 -17
- 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 +0 -1
- data/lib/rigor/inference/scope_indexer/constant_ivar_value.rb +93 -0
- data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +58 -15
- 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.rb +137 -46
- data/lib/rigor/inference/self_call_ivar_writes.rb +84 -12
- 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 +2328 -138
- data/lib/rigor/inference/struct_fold_safety.rb +4 -6
- data/lib/rigor/inference/synthetic_method_scanner.rb +5 -6
- 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 +3 -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 +8 -3
- 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 +39 -1
- 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 +7 -3
- 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 +73 -2
- data/lib/rigor/mcp.rb +1 -1
- data/lib/rigor/plugin/access_denied_error.rb +1 -2
- 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 +9 -13
- data/lib/rigor/plugin/registry.rb +3 -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 +44 -5
- 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 +15 -1
- 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 +365 -31
- 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 +371 -58
- 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 +3 -5
- 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/renderer.rb +15 -9
- data/lib/rigor/sig_gen/skip_reason_catalog.rb +15 -2
- 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 +9 -13
- data/lib/rigor/type/constant.rb +4 -5
- data/lib/rigor/type/hash_shape.rb +21 -40
- 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 +38 -36
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +4 -2
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -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 +66 -19
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -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-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 +1 -1
- 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 +1 -1
- 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 +3 -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/sig/rigor/analysis/baseline.rbs +0 -3
- 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/registry.rbs +2 -2
- data/sig/rigor/inference.rbs +4 -1
- data/sig/rigor/plugin/additional_initializer.rbs +0 -5
- data/sig/rigor/plugin/base.rbs +1 -0
- data/sig/rigor/plugin/effect_attribution.rbs +3 -3
- data/sig/rigor/plugin/effect_edge.rbs +1 -1
- data/sig/rigor/plugin/effect_entry_points.rbs +3 -3
- data/sig/rigor/plugin/io_boundary.rbs +2 -1
- data/sig/rigor/plugin/manifest.rbs +9 -9
- data/sig/rigor/plugin/protocol_contract.rbs +3 -7
- data/sig/rigor/scope.rbs +23 -3
- 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 +36 -3
|
@@ -14,7 +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"
|
|
17
18
|
require_relative "callee_mutation_chain"
|
|
19
|
+
require_relative "toplevel_def_census"
|
|
20
|
+
require_relative "callee_write_census"
|
|
18
21
|
require_relative "captured_locals"
|
|
19
22
|
require_relative "dynamic_origin"
|
|
20
23
|
require_relative "error_info"
|
|
@@ -26,10 +29,13 @@ require_relative "../analysis/check_rules/published_constant_guard"
|
|
|
26
29
|
require_relative "struct_fold_safety"
|
|
27
30
|
require_relative "closure_escape_analyzer"
|
|
28
31
|
require_relative "content_join"
|
|
32
|
+
require_relative "construction_escape"
|
|
29
33
|
require_relative "define_method_block_self"
|
|
30
34
|
require_relative "macro_block_self_type"
|
|
35
|
+
require_relative "global_writer_reach"
|
|
31
36
|
require_relative "guard_rebinding"
|
|
32
37
|
require_relative "self_call_ivar_writes"
|
|
38
|
+
require_relative "direct_ivar_writes"
|
|
33
39
|
require_relative "intervening_ivar_reset"
|
|
34
40
|
require_relative "match_rebinding"
|
|
35
41
|
require_relative "clause_widening"
|
|
@@ -42,6 +48,7 @@ require_relative "method_dispatcher"
|
|
|
42
48
|
require_relative "method_parameter_binder"
|
|
43
49
|
require_relative "multi_target_binder"
|
|
44
50
|
require_relative "mutation_widening"
|
|
51
|
+
require_relative "out_buffer_mutation"
|
|
45
52
|
require_relative "narrowing"
|
|
46
53
|
require_relative "operand_effects"
|
|
47
54
|
require_relative "operand_walk"
|
|
@@ -54,6 +61,7 @@ require_relative "rewrite_mutation"
|
|
|
54
61
|
require_relative "unknown_store_widening"
|
|
55
62
|
require_relative "untyped_rejoin"
|
|
56
63
|
require_relative "version_guard"
|
|
64
|
+
require_relative "../run_scoped_slots"
|
|
57
65
|
|
|
58
66
|
module Rigor
|
|
59
67
|
module Inference
|
|
@@ -517,9 +525,13 @@ module Rigor
|
|
|
517
525
|
# def hands on to a self-call counts whether or not the callee resolves: the session's empty scope resolves no
|
|
518
526
|
# callee, and without it an edit that starts or stops forwarding a parameter to a mutating callee would leave
|
|
519
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.
|
|
520
531
|
def content_mutated_parameter_positions(def_node)
|
|
521
|
-
slots =
|
|
522
|
-
census = callee_content_mutated_parameters(def_node).values
|
|
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
|
|
523
535
|
each_forwarding_call(def_node, slots) { |_, forwarded| census += forwarded.map { |name, _| slots[name] } }
|
|
524
536
|
positions, keywords = census.uniq.partition { |slot| slot.is_a?(Integer) }
|
|
525
537
|
positions.sort + keywords.sort
|
|
@@ -531,25 +543,691 @@ module Rigor
|
|
|
531
543
|
# callee floor ({#widen_callee_escaped_argument_captures}) floors after the call, and the ones
|
|
532
544
|
# {CapturedLocals.content_mutations} reports as a block's callee-mutated captures.
|
|
533
545
|
def content_mutated_arguments(call_node)
|
|
534
|
-
|
|
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
|
|
535
568
|
# Fast path — only a local passed as an argument can be reported, so a call with none skips the def
|
|
536
569
|
# resolution and the body scan entirely (the overwhelming common case).
|
|
537
|
-
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
|
|
1013
|
+
|
|
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)
|
|
1020
|
+
|
|
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
|
|
538
1057
|
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
541
1172
|
|
|
542
|
-
|
|
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)
|
|
543
1192
|
return NO_ARGUMENT_READS if mutated.empty?
|
|
544
1193
|
|
|
545
1194
|
argument_nodes = call_node.arguments.arguments
|
|
546
|
-
mutated.values.uniq
|
|
547
|
-
|
|
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]
|
|
548
1200
|
argument = argument.value if argument.is_a?(Prism::ImplicitNode)
|
|
549
1201
|
argument if argument.is_a?(Prism::LocalVariableReadNode)
|
|
550
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
|
|
551
1225
|
end
|
|
552
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
|
+
|
|
553
1231
|
# The value `argument_nodes` pass for the keyword `name` (`poke(acc: a)`), or nil.
|
|
554
1232
|
def keyword_argument(argument_nodes, name)
|
|
555
1233
|
hash = argument_nodes.last
|
|
@@ -585,16 +1263,37 @@ module Rigor
|
|
|
585
1263
|
# evaluator's statement and write-back passes ({#with_repeated_or_write_marks}), type every run of a repeating
|
|
586
1264
|
# body from one entry scope, so its slot's gradual type may be what an EARLIER run stored rather than an
|
|
587
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"`.
|
|
588
1273
|
def index_compound_write_value(node)
|
|
589
1274
|
return index_write_stored_type(node, scope) unless node.is_a?(Prism::IndexOrWriteNode)
|
|
590
1275
|
|
|
591
1276
|
current = index_read_type(node, scope)
|
|
592
1277
|
rhs = scope.type_of(node.value, tracer: tracer)
|
|
593
|
-
|
|
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
|
|
594
1284
|
|
|
595
1285
|
index_write_stored_type(node, scope, current: current, rhs: rhs)
|
|
596
1286
|
end
|
|
597
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
|
+
|
|
598
1297
|
private
|
|
599
1298
|
|
|
600
1299
|
attr_reader :scope, :tracer
|
|
@@ -626,11 +1325,11 @@ module Rigor
|
|
|
626
1325
|
end
|
|
627
1326
|
|
|
628
1327
|
# `name = rvalue` evaluates the rvalue under the entry scope (so earlier assignments in a chained `a = b = expr`
|
|
629
|
-
# propagate left-to-right) and binds `name` to the result type.
|
|
630
|
-
# 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.
|
|
631
1329
|
def eval_local_write(node)
|
|
632
1330
|
rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
633
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)
|
|
634
1333
|
# Issue #667 — `m = AppConfig::MODE` makes `m` a copy of a constant the reader's file does not
|
|
635
1334
|
# declare, so `m == :production` folding to `true` is the project's configuration and not a logic
|
|
636
1335
|
# error the author can see. Stamped LAST, across every binding path above: an RHS can be both an
|
|
@@ -641,6 +1340,53 @@ module Rigor
|
|
|
641
1340
|
[rhs_type, bound.with_published_constant_mark(:local, node.name)]
|
|
642
1341
|
end
|
|
643
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
|
+
|
|
644
1390
|
# The binding half of {#eval_local_write}: the three mutually exclusive provenance paths a local write
|
|
645
1391
|
# can take, unchanged from when they were inline.
|
|
646
1392
|
def bind_local_write(node, rhs_type, post_rhs)
|
|
@@ -740,8 +1486,7 @@ module Rigor
|
|
|
740
1486
|
# Slice 7 phase 1 — instance/class/global variable writes. Each handler evaluates the rvalue under the entry scope
|
|
741
1487
|
# and binds the named variable into the post-scope's per-kind binding map. The expression value is the rvalue
|
|
742
1488
|
# type, matching Ruby's semantics. Bindings are method-local: a fresh scope is built at every `def` entry through
|
|
743
|
-
# `build_method_entry_scope
|
|
744
|
-
# lands.
|
|
1489
|
+
# `build_method_entry_scope`; cross-method ivar and cvar state enters it through the class-level seeds.
|
|
745
1490
|
def eval_ivar_write(node)
|
|
746
1491
|
rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
747
1492
|
# Issue #1446 — a write ends a class guard's narrowing of the variable, as it does a global's.
|
|
@@ -980,8 +1725,7 @@ module Rigor
|
|
|
980
1725
|
# narrowing keyed on `(receiver_kind, receiver_name,
|
|
981
1726
|
# literal_key)` when both receiver and key are stable
|
|
982
1727
|
# (see {Inference::IndexedNarrowing}). Unstable shapes
|
|
983
|
-
#
|
|
984
|
-
# `Prism::IndexOrWriteNode` default-branch behaviour.
|
|
1728
|
+
# record no narrowing.
|
|
985
1729
|
#
|
|
986
1730
|
# The expression value is the result type, matching Ruby's semantics: `(x = params[:f] ||= []); x` observes the
|
|
987
1731
|
# post-`||=` value, not the rvalue alone.
|
|
@@ -1029,6 +1773,29 @@ module Rigor
|
|
|
1029
1773
|
erased || stored
|
|
1030
1774
|
end
|
|
1031
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
|
+
|
|
1032
1799
|
def erase_memo_container(type)
|
|
1033
1800
|
any = Type::Combinator.untyped
|
|
1034
1801
|
case type
|
|
@@ -1113,6 +1880,28 @@ module Rigor
|
|
|
1113
1880
|
end
|
|
1114
1881
|
end
|
|
1115
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
|
+
|
|
1116
1905
|
# True when {#index_compound_write_value} reads the `||=` `node` as the memoization idiom's rvalue: the slot
|
|
1117
1906
|
# reads wholly gradual, the rvalue can store something truthy, and no block-return pass marked the site.
|
|
1118
1907
|
def memoizing_index_read?(node, current, rhs)
|
|
@@ -1160,17 +1949,46 @@ module Rigor
|
|
|
1160
1949
|
# Both mutator-receiver widenings, against one typing of the call's arguments: the bindings the
|
|
1161
1950
|
# receiver NAMES ({MutationWidening.widen_after_call}) and, when it names none because the
|
|
1162
1951
|
# receiver is an element read into a local, that element's pin inside its container
|
|
1163
|
-
# ({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.
|
|
1164
1954
|
def widen_mutated_receivers(call_node, current_scope)
|
|
1165
1955
|
arg_types = mutator_arg_types(call_node, current_scope)
|
|
1166
1956
|
widened = MutationWidening.widen_after_call(call_node: call_node, current_scope: current_scope,
|
|
1167
|
-
arg_types: arg_types
|
|
1168
|
-
|
|
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)))
|
|
1169
1985
|
end
|
|
1170
1986
|
|
|
1171
1987
|
# `tr!` / `tr_s!` are typed too: whether they can empty a `non-empty-string` turns on their replacement argument.
|
|
1172
1988
|
def mutator_arg_types(call_node, current_scope)
|
|
1173
|
-
|
|
1989
|
+
name = call_node.name
|
|
1990
|
+
unless ContentJoin::CONTENT_ADDERS.include?(name) || StringMutation::TRANSLATORS.include?(name) ||
|
|
1991
|
+
SetMutation::CONTENT_ADDERS.include?(name)
|
|
1174
1992
|
return MutationWidening::NO_ARG_TYPES
|
|
1175
1993
|
end
|
|
1176
1994
|
unless MutationWidening.joinable_receiver?(call_node.receiver, current_scope) ||
|
|
@@ -1440,7 +2258,7 @@ module Rigor
|
|
|
1440
2258
|
sub_eval(node.statements, scope)
|
|
1441
2259
|
end
|
|
1442
2260
|
|
|
1443
|
-
# `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
|
|
1444
2262
|
# shared with every branch (including the else); branches are evaluated independently and merged with
|
|
1445
2263
|
# nil-injection so half-bound names degrade to `T | nil`.
|
|
1446
2264
|
def eval_case(node)
|
|
@@ -1646,6 +2464,11 @@ module Rigor
|
|
|
1646
2464
|
InterveningIvarReset.may_write?(condition, name, before, run_lambdas: true)
|
|
1647
2465
|
when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode
|
|
1648
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))
|
|
1649
2472
|
else
|
|
1650
2473
|
body = when_lambda_body(condition)
|
|
1651
2474
|
GuardRebinding.may_rebind?(condition, before) || (!body.nil? && GuardRebinding.may_rebind?(body, before))
|
|
@@ -3068,7 +3891,9 @@ module Rigor
|
|
|
3068
3891
|
types = [coll_type]
|
|
3069
3892
|
name = for_collection_variable(node)
|
|
3070
3893
|
types << CapturedLocals.bound_type(continuation, name) if name
|
|
3071
|
-
deep = DeepErasure.element_mutation?(node.statements, DeepErasure.parameter_names(node.index))
|
|
3894
|
+
deep = DeepErasure.element_mutation?(node.statements, DeepErasure.parameter_names(node.index)) do
|
|
3895
|
+
DeepErasure.holds_set?(coll_type)
|
|
3896
|
+
end
|
|
3072
3897
|
Type::Combinator.union(*types.compact.map { |type| shape_erased_collection(type, deep: deep) })
|
|
3073
3898
|
end
|
|
3074
3899
|
|
|
@@ -3133,11 +3958,11 @@ module Rigor
|
|
|
3133
3958
|
forget_guard_if_rebinds(body_entry, node.statements)
|
|
3134
3959
|
end
|
|
3135
3960
|
|
|
3136
|
-
# `for x in coll` is semantically `coll.each { |x| ... }`.
|
|
3137
|
-
#
|
|
3138
|
-
#
|
|
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
|
|
3139
3964
|
# the receiver is not a literal Hash carrier in our local lattice). When the dispatcher returns nothing (no
|
|
3140
|
-
# signature, unknown receiver)
|
|
3965
|
+
# signature, unknown receiver) the structural answer stands.
|
|
3141
3966
|
def for_iteration_element_type(coll_type)
|
|
3142
3967
|
structural = collection_element_type(coll_type)
|
|
3143
3968
|
return structural unless structural.equal?(Type::Combinator.untyped)
|
|
@@ -3199,7 +4024,7 @@ module Rigor
|
|
|
3199
4024
|
|
|
3200
4025
|
# Extracts the per-iteration element type from a collection carrier. `Tuple[T1..Tn]` yields the union of its
|
|
3201
4026
|
# elements; `Nominal[Array, [T]]` and `Nominal[Range, [T]]` yield `T`; `Nominal[Hash, [K, V]]` yields `Tuple[K,
|
|
3202
|
-
# 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
|
|
3203
4028
|
# literal range's element class. Anything else falls back to `untyped`.
|
|
3204
4029
|
def collection_element_type(type)
|
|
3205
4030
|
case type
|
|
@@ -3500,8 +4325,9 @@ module Rigor
|
|
|
3500
4325
|
|
|
3501
4326
|
# `class << expr; body; end`. When `expr` is `self`, the body defines class methods on the immediate enclosing
|
|
3502
4327
|
# class — the innermost frame flips to `singleton: true` so a nested `def foo` resolves through `singleton_method`
|
|
3503
|
-
# rather than `instance_method`. For
|
|
3504
|
-
# 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.
|
|
3505
4331
|
# Issue #652 — the body INHERITS this evaluator's chain. Ruby pushes the singleton class onto
|
|
3506
4332
|
# `Module.nesting` and leaves the enclosing entries beneath it, so `class << Other` written inside
|
|
3507
4333
|
# `module Admin` reads `Admin::Y`: the enclosing rung is live, and only the singleton rung itself
|
|
@@ -3567,11 +4393,9 @@ module Rigor
|
|
|
3567
4393
|
invoked = call_operand_scope(node, walk, scope)
|
|
3568
4394
|
invoked = forget_operand_specials(node, invoked)
|
|
3569
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}).
|
|
3570
4398
|
call_type = OperandWalk.type_of(scope, node, tracer, operand_types)
|
|
3571
|
-
# ADR-56 slice C (B3) — `each_with_object(memo) { |x, acc| acc << … }` returns the memo; the engine otherwise
|
|
3572
|
-
# types the call `Dynamic[top]`. Compute the joined memo type from the block's content mutations of the memo
|
|
3573
|
-
# block-param and adopt it as the call's return type.
|
|
3574
|
-
call_type = each_with_object_return(node, call_type)
|
|
3575
4399
|
[call_type, invoke_from(node, invoked, call_type, operand_types)]
|
|
3576
4400
|
end
|
|
3577
4401
|
|
|
@@ -3760,8 +4584,7 @@ module Rigor
|
|
|
3760
4584
|
# B2.2 — intervening method call ivar invalidation. An implicit-self / self-receiver call could mutate any ivar
|
|
3761
4585
|
# of the enclosing class (we cannot prove purity without an effect system). Reset each ivar whose current local
|
|
3762
4586
|
# binding has narrowed below the class-ivar seed back to the seed itself, so a subsequent `if @flag` predicate
|
|
3763
|
-
# observes the seed's union (not the pre-call narrowed value). Always-safe (only widens; no new facts).
|
|
3764
|
-
# [`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).
|
|
3765
4588
|
post_scope = invalidate_ivars_for_intervening_call(node, post_scope) if statement_call
|
|
3766
4589
|
# Issue #1889 — `instance_variable_set` on `self` writes an ivar no write node shows. It runs after the reset
|
|
3767
4590
|
# above, which would join a literal-name write back into the class seed.
|
|
@@ -3795,9 +4618,32 @@ module Rigor
|
|
|
3795
4618
|
post_scope.forget_guard_narrowings
|
|
3796
4619
|
end
|
|
3797
4620
|
end
|
|
4621
|
+
post_scope = forget_reached_globals(node, post_scope)
|
|
3798
4622
|
forget_rescued_status(LastStatus.after(node, post_scope, scope), node)
|
|
3799
4623
|
end
|
|
3800
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
|
+
|
|
3801
4647
|
# True when the call may rebind this frame's match globals: it is match-capable itself ({#match_capable_call?}),
|
|
3802
4648
|
# its own block may run a match ({MatchRebinding.block_may_match?} — issue #1358: the block runs in this frame,
|
|
3803
4649
|
# so `items.each { |i| i =~ re }` rebinds the enclosing method's `$~`, while a match inside a called Ruby method
|
|
@@ -3917,7 +4763,8 @@ module Rigor
|
|
|
3917
4763
|
|
|
3918
4764
|
# Returns a scope with each ivar's narrowed local binding widened back to its class-ivar seed value when the call
|
|
3919
4765
|
# is one that could plausibly mutate ivars on the enclosing class (implicit-self or explicit `self.foo`).
|
|
3920
|
-
# 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.
|
|
3921
4768
|
# Issue #1596 — where the call is proven to write only some ivars ({#self_call_ivar_writes}), only those widen.
|
|
3922
4769
|
#
|
|
3923
4770
|
# Issue #1402 — a proc's call (`blk.call`) is a candidate too, and so is a call on another receiver whose literal
|
|
@@ -3925,11 +4772,19 @@ module Rigor
|
|
|
3925
4772
|
# calls do, marking a binding whose narrowing had ruled `nil` out, since the code they run is far more often
|
|
3926
4773
|
# code that leaves the variable alone. A `yield` or proc call in the call's receiver or arguments
|
|
3927
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.
|
|
3928
4780
|
def invalidate_ivars_for_intervening_call(call_node, current_scope)
|
|
3929
4781
|
widened =
|
|
3930
4782
|
if intervening_call_candidate?(call_node)
|
|
3931
|
-
mark =
|
|
3932
|
-
|
|
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
|
|
3933
4788
|
else
|
|
3934
4789
|
InterveningIvarReset.after_block(call_node, current_scope)
|
|
3935
4790
|
end
|
|
@@ -4119,7 +4974,7 @@ module Rigor
|
|
|
4119
4974
|
# `resolve_call_method`); plugins recognising their own synthetic call shapes (e.g. `T.assert_type!`) have no
|
|
4120
4975
|
# method_def and the parameter facts silently skip — the plugin's own diagnostics_for_file path covers those
|
|
4121
4976
|
# cases. The full plugin-side parameter-targeting story (PHPStan-style Type-Specifying Extensions on
|
|
4122
|
-
# 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.
|
|
4123
4978
|
def apply_plugin_assertions(call_node, current_scope)
|
|
4124
4979
|
registry = current_scope.environment&.plugin_registry
|
|
4125
4980
|
return current_scope if registry.nil? || registry.empty?
|
|
@@ -4274,7 +5129,7 @@ module Rigor
|
|
|
4274
5129
|
def lookup_post_return_arg(call_node, method_def, target_name)
|
|
4275
5130
|
# Plugin-source contributions arrive without an authoritative method_def (the plugin recognised the call shape
|
|
4276
5131
|
# directly). Parameter-targeting falls back to "no narrow" in that case — the wider plugin-side parameter
|
|
4277
|
-
# mapping (`:
|
|
5132
|
+
# mapping (`:argument_at`) is a follow-up slice.
|
|
4278
5133
|
return nil if method_def.nil?
|
|
4279
5134
|
|
|
4280
5135
|
arguments = call_node.arguments&.arguments || []
|
|
@@ -4471,57 +5326,553 @@ module Rigor
|
|
|
4471
5326
|
acc = floor_callee_escaped_args_for_call(receiver, acc)
|
|
4472
5327
|
receiver = receiver.receiver
|
|
4473
5328
|
end
|
|
4474
|
-
|
|
5329
|
+
# Issue #2397 — and every self-call in the call's own non-escaping block.
|
|
5330
|
+
floor_block_callee_captures(node, acc)
|
|
4475
5331
|
end
|
|
4476
5332
|
|
|
4477
5333
|
# Runs for every call of every body it walks, and nearly every call reports no argument, so that case returns
|
|
4478
5334
|
# before `reduce`: `Enumerable#inject` allocates its iteration state even over an empty array.
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
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) }
|
|
4485
5361
|
|
|
4486
|
-
|
|
4487
|
-
|
|
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
|
|
4488
5367
|
end
|
|
5368
|
+
acc
|
|
4489
5369
|
end
|
|
4490
5370
|
|
|
4491
|
-
#
|
|
4492
|
-
#
|
|
4493
|
-
#
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
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
|
|
4500
5381
|
end
|
|
5382
|
+
acc
|
|
4501
5383
|
end
|
|
4502
5384
|
|
|
4503
|
-
# Issue #
|
|
4504
|
-
#
|
|
4505
|
-
#
|
|
4506
|
-
#
|
|
4507
|
-
#
|
|
4508
|
-
# thirty times over (#2295's cost on the release gate).
|
|
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.
|
|
4509
5390
|
#
|
|
4510
|
-
#
|
|
4511
|
-
# and
|
|
4512
|
-
|
|
4513
|
-
|
|
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
|
|
4514
5406
|
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
by_class = (slot.last[kind] ||= {})
|
|
4519
|
-
class_name = enclosing_class_name_for(scope.self_type)
|
|
4520
|
-
by_def = (by_class[class_name] ||= {}.compare_by_identity)
|
|
4521
|
-
by_def.fetch(def_node) { by_def[def_node] = yield }
|
|
5407
|
+
acc = floor_block_body_callee_captures(call, acc)
|
|
5408
|
+
end
|
|
5409
|
+
acc
|
|
4522
5410
|
end
|
|
4523
5411
|
|
|
4524
|
-
|
|
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)
|
|
4525
5876
|
private_constant :RESOLVED_CENSUS_KEY
|
|
4526
5877
|
|
|
4527
5878
|
# The parameters `def_node` mutates itself, from either channel: those escape-mutated inside a block (which may
|
|
@@ -4530,17 +5881,261 @@ module Rigor
|
|
|
4530
5881
|
# ({#def_census}) rather than per evaluator: a chain reads its callees' census from every evaluator that floors a
|
|
4531
5882
|
# call into it.
|
|
4532
5883
|
def own_content_parameters(def_node)
|
|
4533
|
-
def_census(:own, 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
|
|
4534
6001
|
end
|
|
4535
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
|
+
|
|
4536
6129
|
# Issue #2104 — the per-thread memo of a census that reads nothing but `def_node`'s AST, under `kind`.
|
|
4537
|
-
def def_census(kind, def_node)
|
|
6130
|
+
def def_census(kind, def_node, &)
|
|
4538
6131
|
table = (Thread.current[DEF_CENSUS_KEY] ||= ObjectSpace::WeakKeyMap.new)
|
|
4539
6132
|
entry = (table[def_node] ||= {})
|
|
4540
|
-
|
|
6133
|
+
return entry[kind] = yield unless entry.key?(kind)
|
|
6134
|
+
|
|
6135
|
+
MemoAudit.check("def census", entry[kind], kind, &)
|
|
4541
6136
|
end
|
|
4542
6137
|
|
|
4543
|
-
DEF_CENSUS_KEY = :rigor_callee_def_census
|
|
6138
|
+
DEF_CENSUS_KEY = RunScopedSlots.register(:rigor_callee_def_census)
|
|
4544
6139
|
private_constant :DEF_CENSUS_KEY
|
|
4545
6140
|
|
|
4546
6141
|
# Issue #2104 — the {CalleeMutationChain::Edge}s along which `def_node` hands one of its parameters, unchanged, to
|
|
@@ -4553,19 +6148,39 @@ module Rigor
|
|
|
4553
6148
|
cache[def_node] ||= resolved_census(:edges, def_node) { compute_forwarding_edges(def_node) }
|
|
4554
6149
|
end
|
|
4555
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
|
+
|
|
4556
6161
|
def compute_forwarding_edges(def_node)
|
|
4557
|
-
slots =
|
|
6162
|
+
slots = forwarding_slots(def_node)
|
|
4558
6163
|
edges = []
|
|
4559
|
-
each_forwarding_call(def_node, slots) do |call_node, forwarded|
|
|
4560
|
-
|
|
4561
|
-
|
|
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
|
|
4562
6173
|
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
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?
|
|
4567
6180
|
|
|
4568
|
-
|
|
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
|
|
4569
6184
|
end
|
|
4570
6185
|
end
|
|
4571
6186
|
edges.empty? ? NO_FORWARDS : edges
|
|
@@ -4581,11 +6196,13 @@ module Rigor
|
|
|
4581
6196
|
|
|
4582
6197
|
def forwarding_calls(def_node, slots)
|
|
4583
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))
|
|
4584
6201
|
each_node_in_method_scope(def_node.body, 0) do |descendant, depth|
|
|
4585
|
-
next unless self_dispatch_call?(descendant)
|
|
6202
|
+
next unless self_dispatch_call?(descendant) || hand_off_receiver?(descendant)
|
|
4586
6203
|
|
|
4587
|
-
forwarded = forwarded_parameters(descendant, slots, depth)
|
|
4588
|
-
calls << [descendant, forwarded] unless forwarded.empty?
|
|
6204
|
+
forwarded = forwarded_parameters(descendant, slots, depth, context)
|
|
6205
|
+
calls << [descendant, forwarded, depth] unless forwarded.empty?
|
|
4589
6206
|
end
|
|
4590
6207
|
calls.empty? ? NO_FORWARDS : calls
|
|
4591
6208
|
end
|
|
@@ -4596,8 +6213,11 @@ module Rigor
|
|
|
4596
6213
|
# `[[parameter_name, callee_slot], ...]` for the arguments of `call_node` that read one of `slots`' parameters at
|
|
4597
6214
|
# `depth`, the block nesting the call sits at: a read of the method's own parameter there has that depth. An
|
|
4598
6215
|
# argument after a splat or `...` has an index that says nothing about the parameter it binds, so it binds
|
|
4599
|
-
# {ANY_SLOT}, any positional parameter of the callee; one before the first splat keeps its index
|
|
4600
|
-
|
|
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)
|
|
4601
6221
|
args = call_node.arguments
|
|
4602
6222
|
return NO_FORWARDS unless args.is_a?(Prism::ArgumentsNode)
|
|
4603
6223
|
|
|
@@ -4605,11 +6225,14 @@ module Rigor
|
|
|
4605
6225
|
forwarded = []
|
|
4606
6226
|
args.arguments.each_with_index do |argument, index|
|
|
4607
6227
|
if argument.is_a?(Prism::KeywordHashNode)
|
|
4608
|
-
forwarded.concat(forwarded_keywords(argument, slots, depth))
|
|
6228
|
+
forwarded.concat(forwarded_keywords(argument, slots, depth, context))
|
|
4609
6229
|
elsif argument.is_a?(Prism::SplatNode) || argument.is_a?(Prism::ForwardingArgumentsNode)
|
|
4610
6230
|
spread = true
|
|
4611
|
-
|
|
4612
|
-
forwarded << [
|
|
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] }
|
|
4613
6236
|
end
|
|
4614
6237
|
end
|
|
4615
6238
|
forwarded
|
|
@@ -4617,27 +6240,95 @@ module Rigor
|
|
|
4617
6240
|
|
|
4618
6241
|
# The `callee_slot` of an argument whose parameter the call does not show (one after a splat).
|
|
4619
6242
|
ANY_SLOT = :__any_positional__
|
|
4620
|
-
|
|
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
|
|
4621
6248
|
|
|
4622
6249
|
# The names of the callee's parameters (`callee_slots`, {#content_parameter_slots}) an argument bound to `slot`
|
|
4623
6250
|
# may reach: the one at that slot, or every positional one for {ANY_SLOT}.
|
|
4624
6251
|
def bound_parameter_names(callee_slots, slot)
|
|
4625
6252
|
if slot == ANY_SLOT
|
|
4626
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) }
|
|
4627
6256
|
else
|
|
4628
6257
|
callee_slots.filter_map { |name, at| name if at == slot }
|
|
4629
6258
|
end
|
|
4630
6259
|
end
|
|
4631
6260
|
|
|
4632
|
-
def
|
|
4633
|
-
|
|
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
|
|
4634
6269
|
next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
|
|
4635
6270
|
|
|
4636
|
-
|
|
4637
|
-
[name,
|
|
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
|
|
4638
6296
|
end
|
|
4639
6297
|
end
|
|
4640
6298
|
|
|
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
|
|
6303
|
+
|
|
6304
|
+
rest = context.rests[read.name]
|
|
6305
|
+
rest if rest == KEYWORD_REST_PARAMETER
|
|
6306
|
+
end
|
|
6307
|
+
|
|
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
|
|
6315
|
+
end
|
|
6316
|
+
|
|
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
|
|
6330
|
+
end
|
|
6331
|
+
|
|
4641
6332
|
# The parameter `node` reads when it is a read of one of `slots`' names at `depth` (a keyword shorthand
|
|
4642
6333
|
# `poke(acc:)` wraps the read in an `ImplicitNode`), else nil.
|
|
4643
6334
|
def parameter_read_name(node, slots, depth)
|
|
@@ -4647,16 +6338,168 @@ module Rigor
|
|
|
4647
6338
|
node.name if slots.key?(node.name)
|
|
4648
6339
|
end
|
|
4649
6340
|
|
|
4650
|
-
# The
|
|
4651
|
-
# `
|
|
4652
|
-
#
|
|
4653
|
-
def
|
|
4654
|
-
|
|
4655
|
-
|
|
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.
|
|
6345
|
+
#
|
|
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.
|
|
6354
|
+
#
|
|
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?
|
|
4656
6418
|
|
|
4657
|
-
|
|
6419
|
+
missing + top.map { |def_node, offset, _owner| [def_node, offset, FLOOR_EVERY] }
|
|
4658
6420
|
end
|
|
4659
6421
|
|
|
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))
|
|
6431
|
+
|
|
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
|
|
6439
|
+
|
|
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)
|
|
6442
|
+
|
|
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?
|
|
6446
|
+
end
|
|
6447
|
+
|
|
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)
|
|
6454
|
+
end
|
|
6455
|
+
end
|
|
6456
|
+
|
|
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
|
|
6465
|
+
end
|
|
6466
|
+
found && [*found, walked_owner(defining, class_name, nil)]
|
|
6467
|
+
end
|
|
6468
|
+
|
|
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
|
|
6477
|
+
end
|
|
6478
|
+
|
|
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
|
|
6492
|
+
|
|
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)
|
|
6497
|
+
end
|
|
6498
|
+
|
|
6499
|
+
SEND_HAND_OFFS = %i[send public_send __send__].freeze
|
|
6500
|
+
NO_CALLEES = [].freeze
|
|
6501
|
+
private_constant :SEND_HAND_OFFS, :NO_CALLEES
|
|
6502
|
+
|
|
4660
6503
|
def self_dispatch_call?(node)
|
|
4661
6504
|
return false unless node.is_a?(Prism::CallNode)
|
|
4662
6505
|
|
|
@@ -4664,7 +6507,7 @@ module Rigor
|
|
|
4664
6507
|
end
|
|
4665
6508
|
|
|
4666
6509
|
# The set of `[name, slot]` parameters of `def_node` whose content a block in the body escape-mutates.
|
|
4667
|
-
# Memoised per def node (the body walk is otherwise repeated at every call site). A parameter is "escape-
|
|
6510
|
+
# Memoised per def node (the body walk is otherwise repeated at every call site). A parameter is "escape-mutated"
|
|
4668
6511
|
# when a `param[k] = v` / `param << x` mutation on it appears inside a block whose receiving call is not proven
|
|
4669
6512
|
# non-escaping.
|
|
4670
6513
|
def escaped_content_parameters(def_node)
|
|
@@ -4681,8 +6524,10 @@ module Rigor
|
|
|
4681
6524
|
next unless descendant.is_a?(Prism::CallNode) && descendant.block.is_a?(Prism::BlockNode)
|
|
4682
6525
|
next if syntactically_non_escaping_call?(descendant)
|
|
4683
6526
|
|
|
4684
|
-
collect_content_mutations(descendant.block.body).
|
|
4685
|
-
|
|
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) }
|
|
4686
6531
|
end
|
|
4687
6532
|
end
|
|
4688
6533
|
positions.slice(*mutated)
|
|
@@ -4694,9 +6539,9 @@ module Rigor
|
|
|
4694
6539
|
# parameter-name read is genuinely at method scope (depth 0) rather than a block-local of the same name that
|
|
4695
6540
|
# merely shadows the parameter — so we never floor a caller argument the callee did not actually mutate.
|
|
4696
6541
|
#
|
|
4697
|
-
# A mutation inside a NON-escaping block (`n.times { |i| acc[i] = v }`) is not counted: the bare-collection
|
|
4698
|
-
#
|
|
4699
|
-
#
|
|
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.
|
|
4700
6545
|
def direct_content_parameters(def_node)
|
|
4701
6546
|
cache = (@direct_param_cache ||= {}.compare_by_identity)
|
|
4702
6547
|
cache[def_node] ||= compute_direct_content_parameters(def_node)
|
|
@@ -4709,11 +6554,110 @@ module Rigor
|
|
|
4709
6554
|
mutated = Set.new
|
|
4710
6555
|
each_node_outside_nested_scopes(def_node.body) do |descendant|
|
|
4711
6556
|
name, = content_mutation_target(descendant) { |r| r.is_a?(Prism::LocalVariableReadNode) && r.depth.zero? }
|
|
4712
|
-
mutated << name if !name.nil? && positions.key?(name)
|
|
6557
|
+
mutated << name if !name.nil? && positions.key?(name) && !after_rebind?(def_node, name, descendant)
|
|
4713
6558
|
end
|
|
4714
6559
|
positions.slice(*mutated)
|
|
4715
6560
|
end
|
|
4716
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
|
+
|
|
4717
6661
|
# Yields every node reachable from `body` without crossing into a nested block / def / lambda — i.e. the nodes
|
|
4718
6662
|
# that execute in the method's own scope. A local-variable read found here has depth 0 relative to the method, so
|
|
4719
6663
|
# a content mutation whose receiver is a positional-parameter name is a genuine mutation of that parameter.
|
|
@@ -4766,7 +6710,7 @@ module Rigor
|
|
|
4766
6710
|
end
|
|
4767
6711
|
|
|
4768
6712
|
# A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s non-escaping verdict, used when scanning
|
|
4769
|
-
# 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
|
|
4770
6714
|
# structural iterator (`each` / `map` / `tap` / …) runs its block synchronously and does not retain it, so its
|
|
4771
6715
|
# captured mutations are not a cross-boundary escape. Any other name (`on`, `subscribe`, `define_method`, an
|
|
4772
6716
|
# unknown DSL hook) is treated as escaping — sound, since mis-classifying a truly-non-escaping call only floors an
|
|
@@ -4812,6 +6756,8 @@ module Rigor
|
|
|
4812
6756
|
case arg
|
|
4813
6757
|
when Prism::LocalVariableReadNode
|
|
4814
6758
|
scope.locals.key?(arg.name)
|
|
6759
|
+
when Prism::SplatNode
|
|
6760
|
+
arg.expression.is_a?(Prism::LocalVariableReadNode) && scope.locals.key?(arg.expression.name)
|
|
4815
6761
|
when Prism::KeywordHashNode
|
|
4816
6762
|
arg.elements.any? do |pair|
|
|
4817
6763
|
next false unless pair.is_a?(Prism::AssocNode)
|
|
@@ -4907,6 +6853,18 @@ module Rigor
|
|
|
4907
6853
|
Type::Combinator.untyped])
|
|
4908
6854
|
elsif arrayish?(type)
|
|
4909
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
|
|
4910
6868
|
end
|
|
4911
6869
|
end
|
|
4912
6870
|
|
|
@@ -5021,7 +6979,7 @@ module Rigor
|
|
|
5021
6979
|
# ADR-56 slice A. For a `:non_escaping` block, fold the continuation binding of every outer local the body can
|
|
5022
6980
|
# rebind back into `post_scope`. The binding is a capped fixpoint (cap 3) over the block body re-evaluated under
|
|
5023
6981
|
# the running per-name assumption, joined with the pre-call binding (kept as a constituent so the 0-iteration path
|
|
5024
|
-
# — `[].each { … }` — stays sound), value-pinned
|
|
6982
|
+
# — `[].each { … }` — stays sound), value-pinned widened on the final permitted iteration, and floored to
|
|
5025
6983
|
# `Dynamic[top]` on non-convergence (matching `drop_captured_narrowing`).
|
|
5026
6984
|
#
|
|
5027
6985
|
# The instance variables the body rebinds (`CapturedLocals.writes` with `ivars: true`) outlive the call the same
|
|
@@ -5641,7 +7599,7 @@ module Rigor
|
|
|
5641
7599
|
end
|
|
5642
7600
|
|
|
5643
7601
|
# ADR-56 slice C (B3). For `recv.each_with_object(memo) { |x, acc| … }` the return is the memo object after the
|
|
5644
|
-
# 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
|
|
5645
7603
|
# mutations are joined: pre-state = the memo argument's type, added evidence = the content-mutator args on the
|
|
5646
7604
|
# memo block param. Returns `call_type` unchanged for any other call, a missing block, or a memo whose pre-state
|
|
5647
7605
|
# is not a collection.
|
|
@@ -5660,28 +7618,239 @@ module Rigor
|
|
|
5660
7618
|
body = block.body
|
|
5661
7619
|
return call_type if body.nil?
|
|
5662
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
|
+
|
|
5663
7634
|
# The memo alias is a block-local (depth 0) — collect content mutations on it directly rather than via the
|
|
5664
7635
|
# captured-local walk.
|
|
5665
7636
|
shadows = {}.compare_by_identity
|
|
5666
7637
|
calls = body_content_mutations_on(body, memo_param, shadows)
|
|
5667
|
-
|
|
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)
|
|
5668
7655
|
|
|
5669
|
-
|
|
5670
|
-
|
|
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
|
|
7663
|
+
end
|
|
7664
|
+
end
|
|
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))
|
|
5671
7761
|
end
|
|
5672
|
-
joined = join_memo_content(call_node, memo_param, calls, pre_state, shadows)
|
|
5673
|
-
joined || call_type
|
|
5674
7762
|
end
|
|
5675
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
|
+
|
|
5676
7844
|
# `seed` as the {HashLookupMutation} calls `body` makes on `name` leave it. They add no content, so the join
|
|
5677
7845
|
# never sees them as sites, and a seed read before `widen_after_block` is still the closed shape whose known
|
|
5678
7846
|
# values answer every missing key: `b = { a: 1 }; [1].each { b.default = 0; b[:c] = 2 }` read `b[:zz]` as
|
|
5679
7847
|
# `1 | 2`, and so did an `each_with_object({})` memo given a default beside its stores, and a `while` body. The
|
|
5680
7848
|
# block receives a read's `depth` and its enclosing block count, and says whether the read is the variable
|
|
5681
|
-
# `seed` describes. A `def` opens a scope of its own, so nothing under one is.
|
|
5682
|
-
|
|
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)
|
|
5683
7852
|
Source::NodeWalker.each_with_ancestors(body) do |node, ancestors|
|
|
5684
|
-
next unless node.is_a?(Prism::CallNode) &&
|
|
7853
|
+
next unless node.is_a?(Prism::CallNode) && mutators.include?(node.name)
|
|
5685
7854
|
next if ancestors.any?(Prism::DefNode)
|
|
5686
7855
|
|
|
5687
7856
|
receiver = node.receiver
|
|
@@ -5723,21 +7892,19 @@ module Rigor
|
|
|
5723
7892
|
second.respond_to?(:name) ? second.name : nil
|
|
5724
7893
|
end
|
|
5725
7894
|
|
|
5726
|
-
# Content
|
|
5727
|
-
#
|
|
5728
|
-
#
|
|
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 `{}`.
|
|
5729
7900
|
def body_content_mutations_on(body, var_name, shadows)
|
|
5730
7901
|
calls = []
|
|
5731
7902
|
Source::NodeWalker.each_with_ancestors(body) do |descendant, ancestors|
|
|
5732
|
-
|
|
5733
|
-
next unless
|
|
5734
|
-
|
|
5735
|
-
receiver = descendant.receiver
|
|
5736
|
-
next unless receiver.is_a?(Prism::LocalVariableReadNode)
|
|
5737
|
-
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
|
|
5738
7905
|
|
|
5739
|
-
calls <<
|
|
5740
|
-
record_shadows(shadows,
|
|
7906
|
+
calls << site
|
|
7907
|
+
record_shadows(shadows, site, ancestors)
|
|
5741
7908
|
end
|
|
5742
7909
|
calls
|
|
5743
7910
|
end
|
|
@@ -5877,6 +8044,29 @@ module Rigor
|
|
|
5877
8044
|
MutationWidening::HASH_MUTATORS)).freeze
|
|
5878
8045
|
private_constant :CONTENT_MUTATORS
|
|
5879
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
|
+
|
|
5880
8070
|
# The shared "not a content mutation" answer. This predicate runs on every node of every block, loop and
|
|
5881
8071
|
# method body it censuses (~950k calls on the lib self-check) and almost always declines, so a fresh
|
|
5882
8072
|
# `[nil, nil]` per decline was one of the largest allocation sites in the evaluator.
|