rigortype 0.3.6 → 0.3.8
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 +1 -1
- data/data/builtins/ruby_core/date.yml +6 -3
- data/data/builtins/ruby_core/enumerable.yml +1 -0
- data/data/builtins/ruby_core/exception.yml +2 -1
- data/data/builtins/ruby_core/file.yml +28 -14
- data/data/builtins/ruby_core/hash.yml +5 -2
- data/data/builtins/ruby_core/io.yml +33 -16
- data/data/builtins/ruby_core/random.yml +2 -1
- data/data/builtins/ruby_core/re.yml +6 -3
- data/data/builtins/ruby_core/struct.yml +2 -0
- data/data/builtins/ruby_core/time.yml +2 -1
- data/data/gem_overlay/activesupport/core_ext.rbs +409 -11
- data/docs/handbook/11-sig-gen.md +24 -14
- data/docs/manual/02-cli-reference.md +23 -7
- data/docs/manual/04-diagnostics.md +1 -1
- data/docs/manual/05-inspecting-types.md +20 -8
- data/docs/manual/07-plugins.md +4 -0
- data/docs/manual/plugins/rigor-actionpack.md +61 -0
- data/docs/manual/plugins/rigor-activerecord.md +74 -1
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
- data/docs/manual/plugins/rigor-sidekiq.md +22 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
- data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
- data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
- data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
- data/lib/rigor/analysis/check_rules.rb +383 -48
- data/lib/rigor/analysis/crash_signature.rb +188 -0
- data/lib/rigor/analysis/dependency_recorder.rb +18 -3
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/incremental.rb +28 -0
- data/lib/rigor/analysis/incremental_session.rb +63 -16
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
- data/lib/rigor/analysis/reachability/graph.rb +14 -5
- data/lib/rigor/analysis/reachability/scan.rb +7 -3
- data/lib/rigor/analysis/result.rb +32 -0
- data/lib/rigor/analysis/run_cache_key.rb +41 -1
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +233 -16
- data/lib/rigor/analysis/runner/pool_coordinator.rb +266 -47
- data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
- data/lib/rigor/analysis/runner/run_snapshots.rb +12 -2
- data/lib/rigor/analysis/runner.rb +212 -16
- data/lib/rigor/analysis/worker_session.rb +37 -3
- data/lib/rigor/bleeding_edge.rb +7 -6
- data/lib/rigor/builtins/hkt_builtins.rb +45 -6
- data/lib/rigor/builtins/imported_refinements.rb +11 -9
- data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
- data/lib/rigor/builtins/static_return_refinements.rb +20 -1
- data/lib/rigor/cache/annotation_location.rb +72 -0
- data/lib/rigor/cache/descriptor.rb +42 -2
- data/lib/rigor/cache/incremental_snapshot.rb +40 -3
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +70 -8
- data/lib/rigor/cache/store.rb +17 -1
- data/lib/rigor/cli/coverage_command.rb +23 -18
- data/lib/rigor/cli/coverage_mutation.rb +13 -17
- data/lib/rigor/cli/coverage_scan.rb +47 -6
- data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
- data/lib/rigor/cli/fused_protection_report.rb +23 -2
- data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
- data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
- data/lib/rigor/cli/mutation_protection_report.rb +31 -2
- data/lib/rigor/cli/sig_gen_command.rb +47 -0
- data/lib/rigor/cli/type_of_command.rb +188 -41
- data/lib/rigor/cli/type_of_renderer.rb +72 -3
- data/lib/rigor/cli/type_scan_command.rb +10 -1
- data/lib/rigor/cli.rb +1 -1
- data/lib/rigor/configuration.rb +7 -4
- data/lib/rigor/effects/plugin_facts.rb +1 -1
- data/lib/rigor/effects/scanner.rb +2 -3
- data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
- data/lib/rigor/environment/failure_slot.rb +28 -0
- data/lib/rigor/environment/lockfile_resolver.rb +15 -5
- data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
- data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
- data/lib/rigor/environment/rbs_loader.rb +743 -93
- data/lib/rigor/environment.rb +125 -25
- data/lib/rigor/inference/acceptance.rb +172 -20
- data/lib/rigor/inference/captured_locals.rb +62 -0
- data/lib/rigor/inference/content_join.rb +347 -0
- data/lib/rigor/inference/def_handle.rb +15 -3
- data/lib/rigor/inference/def_node_resolver.rb +62 -3
- data/lib/rigor/inference/expression_typer.rb +1020 -141
- data/lib/rigor/inference/fork_map.rb +6 -1
- data/lib/rigor/inference/hkt_reducer.rb +12 -1
- data/lib/rigor/inference/hkt_registry.rb +61 -3
- data/lib/rigor/inference/hkt_sugar_translator.rb +87 -0
- data/lib/rigor/inference/index_write_widening.rb +48 -0
- data/lib/rigor/inference/indexed_narrowing.rb +14 -0
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
- data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
- data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
- data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
- data/lib/rigor/inference/method_dispatcher.rb +155 -14
- data/lib/rigor/inference/method_parameter_binder.rb +3 -3
- data/lib/rigor/inference/mutation_widening.rb +244 -174
- data/lib/rigor/inference/narrowing.rb +331 -27
- data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
- data/lib/rigor/inference/pre_eval_constants.rb +15 -0
- data/lib/rigor/inference/precision_scanner.rb +5 -2
- data/lib/rigor/inference/project_patched_scanner.rb +2 -1
- data/lib/rigor/inference/rbs_type_translator.rb +89 -39
- data/lib/rigor/inference/scope_indexer.rb +1493 -140
- data/lib/rigor/inference/statement_evaluator.rb +276 -91
- data/lib/rigor/inference/struct_fold_safety.rb +186 -27
- data/lib/rigor/inference/version_guard.rb +229 -0
- data/lib/rigor/language_server/selection_range_provider.rb +1 -1
- data/lib/rigor/plugin/base.rb +3 -2
- data/lib/rigor/plugin/inflector.rb +14 -5
- data/lib/rigor/plugin/io_boundary.rb +107 -4
- data/lib/rigor/plugin/loader.rb +14 -0
- data/lib/rigor/plugin/registry.rb +25 -2
- data/lib/rigor/protection/analysis_guard.rb +60 -0
- data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
- data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
- data/lib/rigor/protection/discovery_seed.rb +1 -1
- data/lib/rigor/protection/measurement_integrity.rb +36 -0
- data/lib/rigor/protection/mutation_scanner.rb +61 -6
- data/lib/rigor/protection/mutator.rb +121 -18
- data/lib/rigor/rbs_extended/envelope_scanner.rb +8 -7
- data/lib/rigor/rbs_extended/hkt_directives.rb +16 -1
- data/lib/rigor/rbs_extended/reporter.rb +93 -13
- data/lib/rigor/rbs_extended.rb +6 -1
- data/lib/rigor/reflection.rb +182 -30
- data/lib/rigor/scope/discovery_index.rb +39 -1
- data/lib/rigor/scope.rb +385 -4
- data/lib/rigor/sig_gen/classification.rb +12 -1
- data/lib/rigor/sig_gen/generator.rb +320 -85
- data/lib/rigor/sig_gen/renderer.rb +14 -12
- data/lib/rigor/sig_gen/writer.rb +3 -0
- data/lib/rigor/source/constant_path.rb +79 -2
- data/lib/rigor/triage/catalogue.rb +1 -1
- data/lib/rigor/type/combinator.rb +10 -0
- data/lib/rigor/type/maybe.rb +47 -0
- data/lib/rigor/type/refined.rb +1 -2
- data/lib/rigor/type/result.rb +53 -0
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
- data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
- data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
- data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
- data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
- data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
- data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
- data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
- data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
- data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
- data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
- data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
- data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
- data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
- data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
- data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
- data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
- data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
- data/sig/rigor/analysis/baseline.rbs +1 -1
- data/sig/rigor/environment.rbs +9 -0
- data/sig/rigor/inference.rbs +4 -1
- data/sig/rigor/plugin/io_boundary.rbs +2 -0
- data/sig/rigor/reflection.rbs +3 -1
- data/sig/rigor/scope.rbs +25 -1
- data/sig/rigor/type.rbs +95 -1
- data/sig/rigor.rbs +16 -0
- metadata +71 -3
|
@@ -7,10 +7,14 @@ require_relative "../reflection"
|
|
|
7
7
|
require_relative "../ast"
|
|
8
8
|
require_relative "../source/constant_path"
|
|
9
9
|
require_relative "../source/node_children"
|
|
10
|
+
require_relative "../source/node_walker"
|
|
10
11
|
require_relative "../analysis/self_call_resolution_recorder"
|
|
11
12
|
require_relative "block_parameter_binder"
|
|
13
|
+
require_relative "method_parameter_binder"
|
|
12
14
|
require_relative "body_fixpoint"
|
|
13
15
|
require_relative "budget_trace"
|
|
16
|
+
require_relative "captured_locals"
|
|
17
|
+
require_relative "def_node_resolver"
|
|
14
18
|
require_relative "dynamic_origin"
|
|
15
19
|
require_relative "origin_lookup"
|
|
16
20
|
require_relative "../effects/collector"
|
|
@@ -19,10 +23,13 @@ require_relative "flow_tracer"
|
|
|
19
23
|
require_relative "indexed_narrowing"
|
|
20
24
|
require_relative "macro_block_self_type"
|
|
21
25
|
require_relative "method_dispatcher"
|
|
26
|
+
require_relative "mutation_widening"
|
|
22
27
|
require_relative "narrowing"
|
|
28
|
+
require_relative "receiver_alias"
|
|
23
29
|
require_relative "singleton_object_constant"
|
|
24
30
|
require_relative "optimistic_origin"
|
|
25
31
|
require_relative "struct_fold_safety"
|
|
32
|
+
require_relative "version_guard"
|
|
26
33
|
|
|
27
34
|
module Rigor
|
|
28
35
|
module Inference
|
|
@@ -51,7 +58,7 @@ module Rigor
|
|
|
51
58
|
# docs/internal-spec/inference-engine.md. The optional tracer is a Rigor::Inference::FallbackTracer (or
|
|
52
59
|
# any object answering #record_fallback) that receives a Fallback event for each fallback; the tracer
|
|
53
60
|
# MUST NOT change the return value of type_of.
|
|
54
|
-
# rubocop:disable Metrics/ClassLength
|
|
61
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
55
62
|
class ExpressionTyper
|
|
56
63
|
# Hash-based dispatch keeps `type_of` linear and lets future slices add node kinds without growing a
|
|
57
64
|
# single case statement past RuboCop's cyclomatic budget. Anonymous Prism subclasses are not expected.
|
|
@@ -83,6 +90,9 @@ module Rigor
|
|
|
83
90
|
Prism::ProgramNode => :type_of_program,
|
|
84
91
|
# Calls
|
|
85
92
|
Prism::CallNode => :call_type_for,
|
|
93
|
+
Prism::CallOrWriteNode => :call_or_write_type_for,
|
|
94
|
+
Prism::CallAndWriteNode => :call_and_write_type_for,
|
|
95
|
+
Prism::CallOperatorWriteNode => :call_operator_write_type_for,
|
|
86
96
|
Prism::ArgumentsNode => :type_of_non_value,
|
|
87
97
|
# Constants
|
|
88
98
|
Prism::ConstantReadNode => :type_of_constant_read,
|
|
@@ -421,11 +431,16 @@ module Rigor
|
|
|
421
431
|
resolve_constant_name(node.name.to_s) || unresolved_constant_fallback(node, node.name.to_s)
|
|
422
432
|
end
|
|
423
433
|
|
|
434
|
+
# A leading `::` (`::Rails`, `::Rails::Application`) is Ruby's escape hatch out of the lexical ladder:
|
|
435
|
+
# it names the top-level constant whatever the enclosing nesting defines. The rendered name is
|
|
436
|
+
# deliberately un-rooted (the discovery tables are keyed that way), so the marker rides alongside it
|
|
437
|
+
# into the resolver (#614).
|
|
424
438
|
def type_of_constant_path(node)
|
|
425
439
|
full_name = Source::ConstantPath.qualified_name_or_nil(node)
|
|
426
440
|
return fallback_for(node, family: :prism) if full_name.nil?
|
|
427
441
|
|
|
428
|
-
resolve_constant_name(full_name
|
|
442
|
+
resolve_constant_name(full_name, rooted: Source::ConstantPath.rooted?(node)) ||
|
|
443
|
+
unresolved_constant_fallback(node, full_name)
|
|
429
444
|
end
|
|
430
445
|
|
|
431
446
|
# ADR-82 WD9 — an unresolved constant whose root name a locked, RBS-less gem declares carries the
|
|
@@ -436,6 +451,7 @@ module Rigor
|
|
|
436
451
|
# `Dynamic[top]`, and an unindexed constant (project typo, unanalyzed project path) keeps the generic
|
|
437
452
|
# cause: the fail-open direction is a missing label, never a wrong one.
|
|
438
453
|
def unresolved_constant_fallback(node, full_name)
|
|
454
|
+
record_missing_constant(full_name) if Analysis::DependencyRecorder.active?
|
|
439
455
|
root = full_name.delete_prefix("::").split("::").first
|
|
440
456
|
owner = root && scope.environment.missing_rbs_gem_owner(root)
|
|
441
457
|
return fallback_for(node, family: :prism) unless owner
|
|
@@ -446,13 +462,50 @@ module Rigor
|
|
|
446
462
|
inner
|
|
447
463
|
end
|
|
448
464
|
|
|
465
|
+
# ADR-46 slice 3 / issue #622 — a constant reference that resolved to NOTHING is a negative cross-file
|
|
466
|
+
# dependency: a file declaring that constant later must re-check every file that read it as missing.
|
|
467
|
+
# Without this edge the structural tier had no record of the read at all — `read_missing(:method, …)`
|
|
468
|
+
# fires only once the receiver constant has RESOLVED and the method lookup misses, so a missing
|
|
469
|
+
# constant short-circuited before any edge existed, and a warm `--incremental` run kept
|
|
470
|
+
# `Rails.logger`'s `Dynamic[top]` (and missed the `call.undefined-method` a full run fires) after a
|
|
471
|
+
# later edit added `module Rails`.
|
|
472
|
+
#
|
|
473
|
+
# Reuses the existing `class:Name` negative kind (`CheckRules`'s override-ancestor miss records the
|
|
474
|
+
# same one) rather than adding a `constant:` kind, because the producer that has to invert it already
|
|
475
|
+
# exists: `Incremental.appeared_classes` reports a newly declared class / module by its QUALIFIED name
|
|
476
|
+
# and `negative_affected` matches it by simple (last-segment) name. The snapshot row grammar is
|
|
477
|
+
# therefore unchanged — no `IncrementalSnapshot::SCHEMA` bump — and a snapshot recorded by an engine
|
|
478
|
+
# without this edge is already dropped by the fingerprint's engine-source part.
|
|
479
|
+
#
|
|
480
|
+
# The LAST segment is the whole key, and one row per reference is the whole cost: a qualified read
|
|
481
|
+
# resolves only once its final segment is declared, and every constant form that resolves cross-file —
|
|
482
|
+
# a `class` / `module`, and the constant-assigned `Data.define` / `Struct.new` forms — registers in the
|
|
483
|
+
# discovery pre-pass's class sources under its qualified name, so its final segment always appears.
|
|
484
|
+
# (A plain value constant, `FOO = 1` or a `VERSION` nested in a module, resolves in NO cross-file read
|
|
485
|
+
# today, so there is nothing for a root-segment or a `constant:` key to salvage; if that changes, the
|
|
486
|
+
# answer is a producer that reports appeared value constants, not a wider key here.) Simple-name
|
|
487
|
+
# matching over-invalidates — a nested `MyApp::Rails` also re-checks a reader of the top-level `Rails`
|
|
488
|
+
# — which is the sound direction and the grammar the class negatives already use. Consumers hold
|
|
489
|
+
# `missing` as a Set, so a name repeated across a file still costs one row.
|
|
490
|
+
def record_missing_constant(full_name)
|
|
491
|
+
segments = full_name.delete_prefix("::").split("::")
|
|
492
|
+
return if segments.empty?
|
|
493
|
+
|
|
494
|
+
Analysis::DependencyRecorder.read_missing(:class, segments.last)
|
|
495
|
+
# Issue #644's `constant:<last segment>` edge is NOT recorded here. It is recorded once per
|
|
496
|
+
# reference by `Reflection.resolve_constant_type`, before the resolver ladder runs, so it covers a
|
|
497
|
+
# reference that resolves through RBS or the class registry as well as one that misses — all three
|
|
498
|
+
# answers move when the project's constant write set moves. Recording it again on this path would be
|
|
499
|
+
# a second source of truth for the same key.
|
|
500
|
+
end
|
|
501
|
+
|
|
449
502
|
# Resolves a constant reference through Ruby's lexical constant lookup. Delegates to the shared
|
|
450
503
|
# `Reflection.resolve_constant_type` owner so the same walk (registry singleton, discovered class,
|
|
451
504
|
# in-source value, RBS constant, across the peeled `::` prefix candidates) is reused by
|
|
452
505
|
# `Inference::Narrowing`'s `Constant[Regexp]` match-operand recognition. Returns the matched
|
|
453
506
|
# `Rigor::Type` or nil; the caller decides whether to fall back.
|
|
454
|
-
def resolve_constant_name(name)
|
|
455
|
-
Reflection.resolve_constant_type(name, scope: scope)
|
|
507
|
+
def resolve_constant_name(name, rooted: false)
|
|
508
|
+
Reflection.resolve_constant_type(name, scope: scope, rooted: rooted)
|
|
456
509
|
end
|
|
457
510
|
|
|
458
511
|
# Slice 5 phase 1 upgrades hash literals to `HashShape{...}` when every entry is a static `AssocNode`
|
|
@@ -642,6 +695,14 @@ module Rigor
|
|
|
642
695
|
# `Constant[false]` fold one branch; `Union[true, false]`, `Dynamic[T]`, and `Top` keep both branches live.
|
|
643
696
|
def constant_predicate_polarity(predicate)
|
|
644
697
|
return nil if predicate.nil?
|
|
698
|
+
|
|
699
|
+
# ADR-47 WD5 — a decidable version guard (#627) answers first, exactly as it does on the scope side
|
|
700
|
+
# in `StatementEvaluator#branch_certainty`. Both readers ask the same pure function of the AST, so
|
|
701
|
+
# the expression form (`RUBY_VERSION >= "3.1" ? a : b`) and the statement form cannot disagree about
|
|
702
|
+
# which arm survives. The verdict rests on literals, so the ADR-101 optimistic-carrier decline below
|
|
703
|
+
# — which guards an RBS-derived judgment — does not apply to it.
|
|
704
|
+
guard = VersionGuard.verdict(predicate)
|
|
705
|
+
return guard if guard
|
|
645
706
|
# ADR-101 — decline on an optimistically nil-free carrier; see
|
|
646
707
|
# `StatementEvaluator#optimistic_carrier?` for why the gate is here and not in `Narrowing`.
|
|
647
708
|
return nil unless optimistic_origin_for(predicate).nil?
|
|
@@ -957,10 +1018,10 @@ module Rigor
|
|
|
957
1018
|
end
|
|
958
1019
|
end
|
|
959
1020
|
|
|
960
|
-
def fallback_for(node, family:)
|
|
1021
|
+
def fallback_for(node, family:, origin: DynamicOrigin::UNSUPPORTED_SYNTAX)
|
|
961
1022
|
inner = dynamic_top
|
|
962
|
-
record_fallback(node, family: family, inner_type: inner, origin:
|
|
963
|
-
scope.record_dynamic_origin(node,
|
|
1023
|
+
record_fallback(node, family: family, inner_type: inner, origin: origin)
|
|
1024
|
+
scope.record_dynamic_origin(node, origin)
|
|
964
1025
|
inner
|
|
965
1026
|
end
|
|
966
1027
|
|
|
@@ -1107,9 +1168,19 @@ module Rigor
|
|
|
1107
1168
|
# Issue #316 — the lookup goes through the confidence-gated `Scope#bindable_top_level_def_for`, not the
|
|
1108
1169
|
# raw table: inside a block whose `self` is unmodelled, a top-level `def` from ANOTHER file is not
|
|
1109
1170
|
# evidence about which method the call reaches, so the bind is declined and the call widens.
|
|
1171
|
+
#
|
|
1172
|
+
# Issue #618 — the binding is a fallback for names the enclosing `self` does NOT answer, not a
|
|
1173
|
+
# first-choice tier. A top-level `def` is a private method on `Object`, the last link of every MRO, so
|
|
1174
|
+
# whenever the call's own `self` carries the name the class wins at runtime and the top-level body is
|
|
1175
|
+
# never reached. Binding regardless of `self_type` inverted that: a top-level `def text` typed the
|
|
1176
|
+
# member read inside `class Line < Struct.new(:text); def shout = text.upcase; end` as the def's `nil`
|
|
1177
|
+
# and fired `undefined method 'upcase' for nil` on correct code. The candidate is still looked up
|
|
1178
|
+
# first — that lookup is a hash probe and owns the ADR-46 cross-file dependency edge — and
|
|
1179
|
+
# {#self_type_answers?} then vetoes the bind for a name the enclosing class answers itself.
|
|
1110
1180
|
def try_local_def_dispatch(node, receiver, arg_types)
|
|
1111
1181
|
local_def = node.receiver.nil? ? scope.bindable_top_level_def_for(node.name) : nil
|
|
1112
1182
|
return nil unless local_def
|
|
1183
|
+
return nil if self_type_answers?(node.name)
|
|
1113
1184
|
|
|
1114
1185
|
local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
|
|
1115
1186
|
return local_inference if local_inference
|
|
@@ -1123,15 +1194,193 @@ module Rigor
|
|
|
1123
1194
|
dynamic_top
|
|
1124
1195
|
end
|
|
1125
1196
|
|
|
1197
|
+
# Issue #618 — whether the call's enclosing `self` already answers `method_name`. Only a `self` whose
|
|
1198
|
+
# class is KNOWN participates: at genuine top level, and inside a block whose `self` is unmodelled,
|
|
1199
|
+
# `scope.self_type` is nil, the predicate is false, and the historical top-level binding stands — that
|
|
1200
|
+
# is #316's / #319's territory and this veto stays out of it.
|
|
1201
|
+
#
|
|
1202
|
+
# The ADR-48 member carriers answer from their own map first: a `StructInstance` / `DataInstance`
|
|
1203
|
+
# `self` carries the member set the body reads through, and an anonymous `Struct.new(...)` value has no
|
|
1204
|
+
# class name to look anything else up under.
|
|
1205
|
+
def self_type_answers?(method_name)
|
|
1206
|
+
case (self_type = scope.self_type)
|
|
1207
|
+
when Type::Singleton then singleton_self_answers?(self_type.class_name, method_name)
|
|
1208
|
+
when Type::Nominal then instance_self_answers?(self_type.class_name, method_name)
|
|
1209
|
+
when Type::StructInstance, Type::DataInstance
|
|
1210
|
+
self_type.members.key?(method_name.to_sym) || instance_self_answers?(self_type.class_name, method_name)
|
|
1211
|
+
else false
|
|
1212
|
+
end
|
|
1213
|
+
end
|
|
1214
|
+
|
|
1215
|
+
# The instance side of {#self_type_answers?}: the class's own discovered methods (`def`, `attr_*`,
|
|
1216
|
+
# `define_method`, `alias`), its `Struct.new` / `Data.define` member accessors, a `def` reached through
|
|
1217
|
+
# its project ancestors (superclass chain and included modules), and an RBS method declared on the
|
|
1218
|
+
# class ITSELF.
|
|
1219
|
+
#
|
|
1220
|
+
# The RBS arm is own-class only, deliberately. An inherited-declaration test would match every
|
|
1221
|
+
# `Object` / `Kernel` / `Enumerable` name and retract the binding v0.0.3 A exists for — a `def
|
|
1222
|
+
# select(...)` collocated with its DSL-block call site would route straight back through
|
|
1223
|
+
# `Enumerable#select`.
|
|
1224
|
+
def instance_self_answers?(class_name, method_name)
|
|
1225
|
+
return false if class_name.nil?
|
|
1226
|
+
return true if scope.discovered_method?(class_name, method_name, :instance)
|
|
1227
|
+
return true if meta_member?(class_name, method_name)
|
|
1228
|
+
return true if resolve_user_def_through_ancestors(class_name, method_name)
|
|
1229
|
+
|
|
1230
|
+
rbs_declared_on_class?(safe_rbs_method_definition(class_name, method_name, :instance), class_name)
|
|
1231
|
+
end
|
|
1232
|
+
|
|
1233
|
+
# The singleton side: a class-body `self` is `Singleton[Foo]`, where an implicit-self call reaches
|
|
1234
|
+
# `Foo`'s own class methods before `Object`'s private top-level `def`.
|
|
1235
|
+
def singleton_self_answers?(class_name, method_name)
|
|
1236
|
+
return false if class_name.nil?
|
|
1237
|
+
return true if scope.discovered_method?(class_name, method_name, :singleton)
|
|
1238
|
+
return true unless scope.singleton_def_for(class_name, method_name).nil?
|
|
1239
|
+
|
|
1240
|
+
rbs_declared_on_class?(safe_rbs_method_definition(class_name, method_name, :singleton), class_name)
|
|
1241
|
+
end
|
|
1242
|
+
|
|
1243
|
+
# A `Struct.new(:a, :b)` / `Data.define(:a, :b)` member accessor. The layouts are a discovery table of
|
|
1244
|
+
# their own, separate from `discovered_methods`, so the accessor names they imply have to be asked for
|
|
1245
|
+
# explicitly. Only the reader name is tested: a writer is unreachable as an implicit-self call, since
|
|
1246
|
+
# bare `a = v` is a local assignment.
|
|
1247
|
+
def meta_member?(class_name, method_name)
|
|
1248
|
+
layout = scope.struct_member_layout(class_name)
|
|
1249
|
+
members = layout ? layout[:members] : scope.data_member_layout(class_name)
|
|
1250
|
+
!members.nil? && members.include?(method_name.to_sym)
|
|
1251
|
+
end
|
|
1252
|
+
|
|
1253
|
+
def safe_rbs_method_definition(class_name, method_name, kind)
|
|
1254
|
+
if kind == :singleton
|
|
1255
|
+
Rigor::Reflection.singleton_method_definition(class_name, method_name, scope: scope)
|
|
1256
|
+
else
|
|
1257
|
+
Rigor::Reflection.instance_method_definition(class_name, method_name, scope: scope)
|
|
1258
|
+
end
|
|
1259
|
+
rescue StandardError
|
|
1260
|
+
nil
|
|
1261
|
+
end
|
|
1262
|
+
|
|
1263
|
+
# True when the RBS declaration found for the name sits on `class_name` itself rather than on an
|
|
1264
|
+
# ancestor; mirrors `CheckRules#defined_on?` and `SigGen::Generator#declared_on_class_itself?`.
|
|
1265
|
+
def rbs_declared_on_class?(definition, class_name)
|
|
1266
|
+
return false if definition.nil?
|
|
1267
|
+
return false unless definition.respond_to?(:defined_in)
|
|
1268
|
+
|
|
1269
|
+
defined_in = definition.defined_in
|
|
1270
|
+
return false if defined_in.nil?
|
|
1271
|
+
|
|
1272
|
+
defined_in.to_s.delete_prefix("::") == class_name.to_s.delete_prefix("::")
|
|
1273
|
+
end
|
|
1274
|
+
|
|
1275
|
+
# Issue #520 — Ruby defines the value of an attribute / index assignment (`x.attr = v`, `h[k] = v`)
|
|
1276
|
+
# as the RHS object itself, whatever the writer method returns. The dispatch pipeline still runs
|
|
1277
|
+
# first for everything it observes on the side (effect collection, provenance, recorders, and the
|
|
1278
|
+
# rules' own view of the writer), but its RESULT is discarded in favor of the last argument's type —
|
|
1279
|
+
# `Hash#[]=`'s declared V made `h[k] = true` read Dynamic on an untyped hash, ~300 sites across the
|
|
1280
|
+
# 2026-09-01 corpus sweep. Safe-navigation writes stay on the dispatch result: `x&.attr = v` is
|
|
1281
|
+
# `v | nil`, which is #518's (safe-navigation) territory, not plain value semantics.
|
|
1282
|
+
def call_type_for(node)
|
|
1283
|
+
return safe_navigation_call_type(node) if node.safe_navigation?
|
|
1284
|
+
|
|
1285
|
+
attribute_write_value(node, call_dispatch_type_for(node))
|
|
1286
|
+
end
|
|
1287
|
+
|
|
1288
|
+
# The RHS-value override for plain attribute / index writes (#520); a non-write call keeps the
|
|
1289
|
+
# dispatch result.
|
|
1290
|
+
def attribute_write_value(node, result)
|
|
1291
|
+
return result unless node.attribute_write?
|
|
1292
|
+
|
|
1293
|
+
rhs = node.arguments&.arguments&.last
|
|
1294
|
+
return result if rhs.nil? || rhs.is_a?(Prism::SplatNode)
|
|
1295
|
+
|
|
1296
|
+
type_of(rhs)
|
|
1297
|
+
end
|
|
1298
|
+
|
|
1299
|
+
# Issue #518 — `x&.m` is NOT a plain call: when `x` is nil the method never runs and the expression
|
|
1300
|
+
# is nil. Typed as a plain call it inherited both defects of union dispatch — the nil arm's method
|
|
1301
|
+
# was folded as if `&.` called it (`s&.to_s` on `String?` read `String`, missing the nil the runtime
|
|
1302
|
+
# produces), and a method absent from NilClass declined the whole union to `Dynamic[top]` even when
|
|
1303
|
+
# the non-nil arm is fully typed (herb's own sig declares `Token#value: String`; the `?` alone
|
|
1304
|
+
# discarded it). The call is dispatched on the nil-stripped receiver and the nil the skip produces is
|
|
1305
|
+
# unioned back in; a receiver that IS nil skips the call statically.
|
|
1306
|
+
def safe_navigation_call_type(node)
|
|
1307
|
+
# A literal `nil&.m` is the statically-skipped call and folds to nil. An INFERRED exactly-nil
|
|
1308
|
+
# receiver deliberately does NOT fold: every corpus site with that shape traced to a wrong
|
|
1309
|
+
# upstream nil (an `attr_writer`-backed ivar whose only static write is nil — #541 — or a mutated
|
|
1310
|
+
# literal-shape constant — #540), and folding it turned those latent wrong types into
|
|
1311
|
+
# `flow.always-truthy-condition` firings on working programs. Until those uplinks are honest,
|
|
1312
|
+
# the inferred-nil receiver keeps the plain pipeline's Dynamic.
|
|
1313
|
+
return Type::Combinator.constant_of(nil) if node.receiver.is_a?(Prism::NilNode)
|
|
1314
|
+
|
|
1315
|
+
receiver = type_of(node.receiver)
|
|
1316
|
+
non_nil = Narrowing.narrow_non_nil(receiver)
|
|
1317
|
+
# A Bot or Dynamic fragment cannot improve on the plain pipeline (Bot: the inferred-exactly-nil
|
|
1318
|
+
# receivers trace to the #540 / #541 uplinks; Dynamic: the stripped dispatch re-answers Dynamic),
|
|
1319
|
+
# so both keep the historical path and its flow bookkeeping.
|
|
1320
|
+
return call_dispatch_type_for(node) if non_nil.is_a?(Type::Bot) || non_nil.is_a?(Type::Dynamic)
|
|
1321
|
+
|
|
1322
|
+
result = attribute_write_value(node, call_dispatch_type_for(node, receiver_override: non_nil))
|
|
1323
|
+
# Structural equality: `narrow_non_nil` rebuilds a Union even when it removed nothing, and a
|
|
1324
|
+
# receiver that cannot be nil must not have a phantom nil unioned into its result.
|
|
1325
|
+
return result if non_nil == receiver
|
|
1326
|
+
|
|
1327
|
+
Type::Combinator.union(result, Type::Combinator.constant_of(nil))
|
|
1328
|
+
end
|
|
1329
|
+
|
|
1330
|
+
# Issue #532 — the attribute compound-write family (`x.attr ||= v`, `&&=`, `+=`), the last
|
|
1331
|
+
# genuinely unmodeled value-position constructs the 2026-09-01 corpus census found (they fell to the
|
|
1332
|
+
# `unsupported_syntax` fallback). Value semantics mirror the local / ivar / index siblings:
|
|
1333
|
+
# `||=` is `truthy(read) | rhs`, `&&=` is `falsey(read) | rhs`, and an operator write is the
|
|
1334
|
+
# operator dispatched on the read result. A `&.`-form compound write unions the skipped-call nil in
|
|
1335
|
+
# (#518's rule). Scope effects stay as before (none) — the struct member writeback and shape
|
|
1336
|
+
# widening for these forms are follow-up work recorded on #532.
|
|
1337
|
+
def call_or_write_type_for(node)
|
|
1338
|
+
current = attribute_compound_read_type(node)
|
|
1339
|
+
value = Type::Combinator.union(Narrowing.narrow_truthy(current), type_of(node.value))
|
|
1340
|
+
with_compound_write_safe_nav(node, value)
|
|
1341
|
+
end
|
|
1342
|
+
|
|
1343
|
+
def call_and_write_type_for(node)
|
|
1344
|
+
current = attribute_compound_read_type(node)
|
|
1345
|
+
value = Type::Combinator.union(Narrowing.narrow_falsey(current), type_of(node.value))
|
|
1346
|
+
with_compound_write_safe_nav(node, value)
|
|
1347
|
+
end
|
|
1348
|
+
|
|
1349
|
+
def call_operator_write_type_for(node)
|
|
1350
|
+
current = attribute_compound_read_type(node)
|
|
1351
|
+
result = MethodDispatcher.dispatch(
|
|
1352
|
+
receiver_type: current, method_name: node.binary_operator, arg_types: [type_of(node.value)],
|
|
1353
|
+
environment: scope.environment, call_node: node, scope: scope
|
|
1354
|
+
) || dynamic_top
|
|
1355
|
+
with_compound_write_safe_nav(node, result)
|
|
1356
|
+
end
|
|
1357
|
+
|
|
1358
|
+
def attribute_compound_read_type(node)
|
|
1359
|
+
receiver = type_of(node.receiver)
|
|
1360
|
+
MethodDispatcher.dispatch(
|
|
1361
|
+
receiver_type: receiver, method_name: node.read_name, arg_types: [],
|
|
1362
|
+
environment: scope.environment, call_node: node, scope: scope
|
|
1363
|
+
) || dynamic_top
|
|
1364
|
+
end
|
|
1365
|
+
|
|
1366
|
+
def with_compound_write_safe_nav(node, value)
|
|
1367
|
+
return value unless node.call_operator_loc&.slice == "&."
|
|
1368
|
+
|
|
1369
|
+
Type::Combinator.union(value, Type::Combinator.constant_of(nil))
|
|
1370
|
+
end
|
|
1371
|
+
|
|
1126
1372
|
# Slice 2 routes call expressions through `MethodDispatcher`. The receiver and every argument are typed
|
|
1127
1373
|
# first, then the dispatcher is asked for a result type. A nil result triggers the fail-soft fallback
|
|
1128
1374
|
# for the CallNode itself (the inner type_of calls already record their own fallbacks for unrecognised
|
|
1129
1375
|
# receivers/args, so the tracer captures both the immediate dispatch miss and the deeper cause).
|
|
1130
|
-
|
|
1376
|
+
# `receiver_override` substitutes the receiver type for the whole pipeline (folds, dispatch, the
|
|
1377
|
+
# inference tiers) without re-reading the receiver node — the safe-navigation path (#518) dispatches
|
|
1378
|
+
# on the nil-stripped fragment, and the optional-receiver retry (#519) re-runs the pipeline on it.
|
|
1379
|
+
def call_dispatch_type_for(node, receiver_override: nil)
|
|
1131
1380
|
narrowed = indexed_narrowing_for(node)
|
|
1132
1381
|
return narrowed if narrowed
|
|
1133
1382
|
|
|
1134
|
-
receiver = call_receiver_type_for(node)
|
|
1383
|
+
receiver = receiver_override || call_receiver_type_for(node)
|
|
1135
1384
|
arg_types = call_arg_types(node)
|
|
1136
1385
|
block_type = block_return_type_for(node, receiver, arg_types)
|
|
1137
1386
|
|
|
@@ -1140,6 +1389,9 @@ module Rigor
|
|
|
1140
1389
|
# asks nothing further of dispatch. Off (the default) this is one integer read.
|
|
1141
1390
|
Effects::Collector.record_call(node, receiver, scope) if Effects::Collector.active?
|
|
1142
1391
|
|
|
1392
|
+
literal_send = try_literal_send(node, receiver)
|
|
1393
|
+
return literal_send if literal_send
|
|
1394
|
+
|
|
1143
1395
|
local_def_result = try_local_def_dispatch(node, receiver, arg_types)
|
|
1144
1396
|
return local_def_result if local_def_result
|
|
1145
1397
|
|
|
@@ -1148,14 +1400,8 @@ module Rigor
|
|
|
1148
1400
|
# the corresponding element bound to the block parameter and assemble the results into a
|
|
1149
1401
|
# `Tuple[U_1..U_n]`. This sits ahead of `MethodDispatcher.dispatch` so the RBS tier does not re-widen
|
|
1150
1402
|
# the answer back to `Array[union]`.
|
|
1151
|
-
|
|
1152
|
-
return
|
|
1153
|
-
|
|
1154
|
-
inject_fold = try_block_inject_fold(node, receiver, arg_types)
|
|
1155
|
-
return inject_fold if inject_fold
|
|
1156
|
-
|
|
1157
|
-
hash_transform = try_hash_shape_block_fold(node, receiver)
|
|
1158
|
-
return hash_transform if hash_transform
|
|
1403
|
+
block_fold = try_receiver_block_folds(node, receiver, arg_types)
|
|
1404
|
+
return block_fold if block_fold
|
|
1159
1405
|
|
|
1160
1406
|
result = MethodDispatcher.dispatch(
|
|
1161
1407
|
receiver_type: receiver,
|
|
@@ -1168,6 +1414,12 @@ module Rigor
|
|
|
1168
1414
|
)
|
|
1169
1415
|
return result if result
|
|
1170
1416
|
|
|
1417
|
+
dispatch_miss_result(node, receiver, arg_types)
|
|
1418
|
+
end
|
|
1419
|
+
|
|
1420
|
+
# The post-dispatch tiers for a call `MethodDispatcher` could not answer, in their historical
|
|
1421
|
+
# order; extracted from {#call_dispatch_type_for} whole.
|
|
1422
|
+
def dispatch_miss_result(node, receiver, arg_types)
|
|
1171
1423
|
# v0.0.2 #5 — inter-procedural inference for user-defined methods. When dispatch misses but the
|
|
1172
1424
|
# receiver is a user class with a `def` body, re-type the body with the call's argument types bound
|
|
1173
1425
|
# and return the body's last-expression type.
|
|
@@ -1187,9 +1439,46 @@ module Rigor
|
|
|
1187
1439
|
# semantic outcome, not a fail-soft compromise, so it MUST NOT record a tracer event.
|
|
1188
1440
|
return inherit_receiver_origin(node) if receiver.is_a?(Type::Dynamic)
|
|
1189
1441
|
|
|
1442
|
+
# Issue #519 — a `T | nil` receiver whose dispatch exhausted every tier: the nil arm vetoed the
|
|
1443
|
+
# union (dispatch_union declines when ANY member declines, and the later tiers refuse unions), so
|
|
1444
|
+
# `StringScanner?#scan` lost a type its RBS fully declares. Retry the whole pipeline on the
|
|
1445
|
+
# non-nil fragment and answer its result: the nil path raises, which is `possible-nil-receiver`'s
|
|
1446
|
+
# job (that rule reads the RECEIVER and is untouched); the value describes the path that returns
|
|
1447
|
+
# (ADR-5 optimism, same polarity as the accessor-read nil-drop ADR-58 records). Unions whose
|
|
1448
|
+
# non-nil members still decline fall through unchanged.
|
|
1449
|
+
optional_retry = try_non_nil_receiver_retry(node, receiver)
|
|
1450
|
+
return optional_retry if optional_retry
|
|
1451
|
+
|
|
1190
1452
|
unresolved_call_result(node, receiver)
|
|
1191
1453
|
end
|
|
1192
1454
|
|
|
1455
|
+
def try_non_nil_receiver_retry(node, receiver)
|
|
1456
|
+
return nil unless receiver.is_a?(Type::Union)
|
|
1457
|
+
# When NilClass DEFINES the method (`nil?`, `to_s`, `==`, `inspect`, …), the nil arm is a live
|
|
1458
|
+
# returner, not the veto — the union declined on some other member, and answering the stripped
|
|
1459
|
+
# receiver's result would drop the nil arm's contribution (`(Journal | nil).nil?` must never
|
|
1460
|
+
# answer the non-nil arm's constant-folded `false`). Retry only when the nil path raises.
|
|
1461
|
+
return nil if nil_class_defines?(node.name)
|
|
1462
|
+
|
|
1463
|
+
non_nil = Narrowing.narrow_non_nil(receiver)
|
|
1464
|
+
# Structural equality, not identity: `narrow_non_nil` rebuilds a Union even when it removed
|
|
1465
|
+
# nothing, and an identity check would send a nil-free union that exhausts dispatch through the
|
|
1466
|
+
# retry forever. A Dynamic fragment is excluded too: its retry can only re-answer Dynamic, and
|
|
1467
|
+
# running the pipeline twice for that non-answer perturbs the flow bookkeeping for nothing.
|
|
1468
|
+
return nil if non_nil.is_a?(Type::Bot) || non_nil.is_a?(Type::Dynamic) || non_nil == receiver
|
|
1469
|
+
|
|
1470
|
+
call_dispatch_type_for(node, receiver_override: non_nil)
|
|
1471
|
+
end
|
|
1472
|
+
|
|
1473
|
+
# Conservative when NilClass's definition is unavailable: without the proof that the nil path
|
|
1474
|
+
# raises, the retry stays off.
|
|
1475
|
+
def nil_class_defines?(method_name)
|
|
1476
|
+
definition = Rigor::Reflection.instance_definition("NilClass", scope: scope)
|
|
1477
|
+
return true if definition.nil?
|
|
1478
|
+
|
|
1479
|
+
!definition.methods[method_name.to_sym].nil?
|
|
1480
|
+
end
|
|
1481
|
+
|
|
1193
1482
|
# The engine choke-point where a call has exhausted every resolution tier (RBS dispatch + user-class
|
|
1194
1483
|
# ancestor walk) and falls through to `Dynamic[top]`. Two observational recorders read it, both a
|
|
1195
1484
|
# plain integer read when inactive, and neither changes the answer:
|
|
@@ -1201,9 +1490,85 @@ module Rigor
|
|
|
1201
1490
|
record_unresolved_self_call(node, receiver) if Analysis::SelfCallResolutionRecorder.active?
|
|
1202
1491
|
Effects::Collector.record_unresolved(node, scope.source_path) if Effects::Collector.active?
|
|
1203
1492
|
|
|
1204
|
-
fallback_for(node, family: :prism)
|
|
1493
|
+
fallback_for(node, family: :prism, origin: unresolved_call_origin(receiver, node.name))
|
|
1494
|
+
end
|
|
1495
|
+
|
|
1496
|
+
# Issue #522 — the honest cause for a call the tiers exhausted. When the receiver's class HAS a
|
|
1497
|
+
# discovered project def for the method, the miss is a return the engine could not infer (the
|
|
1498
|
+
# discovered-method tier deliberately declined in favor of body inference, which then declined too —
|
|
1499
|
+
# `MethodDispatcher#try_discovered_method`'s decline arms), which is ADR-82's
|
|
1500
|
+
# `INFERRED_RETURN_UNTYPED`, not "unsupported syntax". Without this, every service-object `#call`
|
|
1501
|
+
# whose body defeats inference reports to `coverage --protection` as a syntax gap. The generic cause
|
|
1502
|
+
# stays for genuinely unresolved names (framework DSL sends, methods no scanned file defines).
|
|
1503
|
+
def unresolved_call_origin(receiver, method_name)
|
|
1504
|
+
class_name, kind = case receiver
|
|
1505
|
+
when Type::Nominal then [receiver.class_name, :instance]
|
|
1506
|
+
when Type::Singleton then [receiver.class_name, :singleton]
|
|
1507
|
+
else [nil, nil]
|
|
1508
|
+
end
|
|
1509
|
+
return DynamicOrigin::UNSUPPORTED_SYNTAX if class_name.nil?
|
|
1510
|
+
return DynamicOrigin::INFERRED_RETURN_UNTYPED if scope.discovered_method?(class_name, method_name, kind)
|
|
1511
|
+
# Issue #530 item 1 — the receiver's own class declares nothing, but its ancestry LEAVES the project
|
|
1512
|
+
# into a locked gem that ships no RBS. That gem is the honest cause: the method is inherited from a
|
|
1513
|
+
# class Rigor cannot see, and the user's action is `add_rbs`, not "report an engine gap".
|
|
1514
|
+
return DynamicOrigin::EXTERNAL_GEM_WITHOUT_RBS if external_gem_reached_through_ancestry?(class_name)
|
|
1515
|
+
|
|
1516
|
+
DynamicOrigin::UNSUPPORTED_SYNTAX
|
|
1517
|
+
end
|
|
1518
|
+
|
|
1519
|
+
# Issue #530 item 1 — whether `class_name`'s ancestry crosses out of the project into a locked, RBS-less
|
|
1520
|
+
# gem. WD9's tagging keys on CONSTANT READS, which catches `Parser::AST::Node` where it is WRITTEN (the
|
|
1521
|
+
# superclass position) and nothing after: every implicit-self call inherited into the subclass body —
|
|
1522
|
+
# `node_parts` on rubocop-ast, and the 125-site `#[]` chain hanging off it — recorded the generic cause,
|
|
1523
|
+
# so the target reported 13 add-rbs against 687 engine-gap where the honest story is a gem with no RBS.
|
|
1524
|
+
#
|
|
1525
|
+
# The boundary is exactly the ancestor name that resolves to NO project class. Inside the project the
|
|
1526
|
+
# walk continues; the first name that leaves is the one whose root segment the missing-gem index can
|
|
1527
|
+
# own. A name that leaves into a gem the index does NOT claim (RBS present, not locked, entry file
|
|
1528
|
+
# unreadable) yields nothing and the generic cause stands — the fail-open direction ADR-82 requires,
|
|
1529
|
+
# where the failure mode is a missing label and never a wrong one.
|
|
1530
|
+
#
|
|
1531
|
+
# Bounded by {Scope::ANCESTOR_WALK_LIMIT}'s spirit rather than its constant: this is a provenance
|
|
1532
|
+
# side-channel on an ALREADY-unresolved call, so a deep hierarchy must cost a bounded amount and then
|
|
1533
|
+
# give up rather than pay for a perfect answer nothing type-checks against.
|
|
1534
|
+
def external_gem_reached_through_ancestry?(class_name)
|
|
1535
|
+
environment = scope.environment
|
|
1536
|
+
return false if environment.nil? || !environment.respond_to?(:missing_rbs_gem_owner)
|
|
1537
|
+
|
|
1538
|
+
seen = {}
|
|
1539
|
+
queue = [class_name.to_s]
|
|
1540
|
+
visited = 0
|
|
1541
|
+
until queue.empty?
|
|
1542
|
+
current = queue.shift
|
|
1543
|
+
next if current.nil? || seen[current]
|
|
1544
|
+
|
|
1545
|
+
seen[current] = true
|
|
1546
|
+
visited += 1
|
|
1547
|
+
return false if visited > EXTERNAL_GEM_ANCESTRY_LIMIT
|
|
1548
|
+
|
|
1549
|
+
return true if ancestry_step_leaves_project?(current, queue, environment)
|
|
1550
|
+
end
|
|
1551
|
+
false
|
|
1552
|
+
end
|
|
1553
|
+
|
|
1554
|
+
# One hop of {#external_gem_reached_through_ancestry?}: pushes `current`'s ancestors that stay INSIDE
|
|
1555
|
+
# the project onto `queue`, and answers whether any that leaves lands in a gem the index claims.
|
|
1556
|
+
def ancestry_step_leaves_project?(current, queue, environment)
|
|
1557
|
+
raw_ancestors = scope.includes_of(current) + [scope.superclass_of(current)].compact
|
|
1558
|
+
raw_ancestors.any? do |raw|
|
|
1559
|
+
resolved = scope.ancestor_name_candidates(current, raw).find { |c| scope.known_user_class?(c) }
|
|
1560
|
+
next queue.push(resolved) && false if resolved
|
|
1561
|
+
|
|
1562
|
+
root = raw.to_s.delete_prefix("::").split("::").first
|
|
1563
|
+
!(root.nil? || environment.missing_rbs_gem_owner(root).nil?)
|
|
1564
|
+
end
|
|
1205
1565
|
end
|
|
1206
1566
|
|
|
1567
|
+
# Deliberately smaller than the dispatch walk's cap: this answers a REPORTING question, and a hierarchy
|
|
1568
|
+
# deeper than this has already told us the receiver is not a simple project class.
|
|
1569
|
+
EXTERNAL_GEM_ANCESTRY_LIMIT = 16
|
|
1570
|
+
private_constant :EXTERNAL_GEM_ANCESTRY_LIMIT
|
|
1571
|
+
|
|
1207
1572
|
# ADR-82 WD6 — carry the receiver's provenance onto the call it produces (returning the unchanged
|
|
1208
1573
|
# `dynamic_top` result), so a specific cause survives a method chain (`x.foo.bar`): without this,
|
|
1209
1574
|
# `.foo` on a Dynamic `x` records nothing and `.bar`'s receiver looks causeless. Side-channel only
|
|
@@ -1326,20 +1691,121 @@ module Rigor
|
|
|
1326
1691
|
end
|
|
1327
1692
|
end
|
|
1328
1693
|
|
|
1329
|
-
|
|
1330
|
-
|
|
1694
|
+
# #525 — instance-side user-method inference accepts the named ADR-48 member carriers alongside
|
|
1695
|
+
# plain nominals: a `Line = Struct.new(...) do ... end` value is a StructInstance whose block defs
|
|
1696
|
+
# live under "Line", and the carrier itself is the right `self` for the body (member reads inside
|
|
1697
|
+
# the def project through it).
|
|
1698
|
+
def user_inference_receiver?(receiver)
|
|
1699
|
+
case receiver
|
|
1700
|
+
when Type::Nominal then true
|
|
1701
|
+
when Type::StructInstance, Type::DataInstance then !receiver.class_name.nil?
|
|
1702
|
+
else false
|
|
1703
|
+
end
|
|
1704
|
+
end
|
|
1705
|
+
|
|
1706
|
+
# The three receiver-shaped block folds, in their historical order (extracted whole from
|
|
1707
|
+
# `call_dispatch_type_for` for method-length budget).
|
|
1708
|
+
def try_receiver_block_folds(node, receiver, arg_types)
|
|
1709
|
+
per_element = try_per_element_block_fold(node, receiver)
|
|
1710
|
+
return per_element if per_element
|
|
1711
|
+
|
|
1712
|
+
inject_fold = try_block_inject_fold(node, receiver, arg_types)
|
|
1713
|
+
return inject_fold if inject_fold
|
|
1714
|
+
|
|
1715
|
+
try_hash_shape_block_fold(node, receiver)
|
|
1716
|
+
end
|
|
1717
|
+
|
|
1718
|
+
# Issue #533 — `x.send(:selector, args)` with a LITERAL symbol is statically `x.selector(args)`:
|
|
1719
|
+
# the private-boundary idiom (protobuf's `send(:get_file_descriptor)` at 84 sites) resolves through
|
|
1720
|
+
# the same dispatch + project-inference tiers as the direct call would. `send` legitimately crosses
|
|
1721
|
+
# visibility, so no public-only gate applies (`public_send` is treated identically — typing the
|
|
1722
|
+
# success path of a call that would raise on a private method is ADR-5 optimism). Declines: a
|
|
1723
|
+
# non-literal selector, a splat / forwarded tail (positional correspondence unknown), or a block at
|
|
1724
|
+
# the call site (block forwarding is out of this slice).
|
|
1725
|
+
LITERAL_SEND_SELECTORS = %i[send __send__ public_send].freeze
|
|
1726
|
+
private_constant :LITERAL_SEND_SELECTORS
|
|
1331
1727
|
|
|
1332
|
-
|
|
1728
|
+
def try_literal_send(node, receiver)
|
|
1729
|
+
return nil unless LITERAL_SEND_SELECTORS.include?(node.name)
|
|
1730
|
+
return nil unless node.block.nil?
|
|
1731
|
+
|
|
1732
|
+
args = node.arguments&.arguments
|
|
1733
|
+
return nil unless args && args.first.is_a?(Prism::SymbolNode)
|
|
1734
|
+
|
|
1735
|
+
rest = args[1..]
|
|
1736
|
+
return nil if rest.any? { |a| a.is_a?(Prism::SplatNode) || a.is_a?(Prism::ForwardingArgumentsNode) }
|
|
1737
|
+
|
|
1738
|
+
inner_name = args.first.unescaped.to_sym
|
|
1739
|
+
inner_args = rest.map { |a| type_of(a) }
|
|
1740
|
+
MethodDispatcher.dispatch(
|
|
1741
|
+
receiver_type: receiver, method_name: inner_name, arg_types: inner_args,
|
|
1742
|
+
block_type: nil, environment: scope.environment, call_node: node, scope: scope
|
|
1743
|
+
) ||
|
|
1744
|
+
try_user_method_inference(receiver, node, inner_args, method_name: inner_name) ||
|
|
1745
|
+
try_project_singleton_inference(receiver, node, inner_args, method_name: inner_name)
|
|
1746
|
+
end
|
|
1747
|
+
|
|
1748
|
+
def try_user_method_inference(receiver, call_node, arg_types, method_name: call_node.name)
|
|
1749
|
+
return nil unless user_inference_receiver?(receiver)
|
|
1750
|
+
|
|
1751
|
+
def_node, owner = resolve_user_def_with_owner(receiver.class_name, method_name)
|
|
1333
1752
|
return nil if def_node.nil?
|
|
1334
1753
|
|
|
1335
|
-
result = infer_user_method_return(def_node, receiver, arg_types
|
|
1754
|
+
result = infer_user_method_return(def_node, receiver, arg_types,
|
|
1755
|
+
self_fold_safe: fold_safe_call_receiver?(call_node, receiver))
|
|
1336
1756
|
return result if result.nil?
|
|
1337
1757
|
|
|
1338
|
-
degrade_if_overridable(result, owner,
|
|
1758
|
+
degrade_if_overridable(result, owner, method_name, :instance)
|
|
1339
1759
|
rescue StandardError
|
|
1340
1760
|
nil
|
|
1341
1761
|
end
|
|
1342
1762
|
|
|
1763
|
+
# Issue #525 — whether the RECEIVER EXPRESSION of this call is one whose struct member map is still
|
|
1764
|
+
# current. Three arms:
|
|
1765
|
+
#
|
|
1766
|
+
# - a call that MATERIALISES the struct (`Point.new(…)`, `Point[…]`, `Struct.new(:x).new(…)`, a
|
|
1767
|
+
# `.with(…)` copy) — the object was created by this very expression, so nothing has run against it;
|
|
1768
|
+
# - a local the fold-safe scan proved is never mutated / aliased / escaped;
|
|
1769
|
+
# - `nil` / `self`, inheriting the CURRENT body's own grant, which is what lets a chain of
|
|
1770
|
+
# implicit-self readers propagate: `Line.new(…).outer` grants `outer`'s body `:self`, and an
|
|
1771
|
+
# `inner` call inside it carries the grant into `inner`'s body.
|
|
1772
|
+
#
|
|
1773
|
+
# The first arm shares `StructFolding`'s materialisation test with the direct member-read gate. Neither
|
|
1774
|
+
# accepts a bare chained call: "chained" is not "fresh" once a method can hand back its own receiver,
|
|
1775
|
+
# and a self-returning fluent builder is ordinary Ruby:
|
|
1776
|
+
#
|
|
1777
|
+
# Line = Struct.new(:text) do
|
|
1778
|
+
# def with_text(v) = (self.text = v; self)
|
|
1779
|
+
# def shout = text.upcase
|
|
1780
|
+
# end
|
|
1781
|
+
# Line.new("a").with_text("z").shout # runtime "Z"
|
|
1782
|
+
#
|
|
1783
|
+
# Accepting the `with_text(…)` receiver as fresh would grant `shout` a member map two statements
|
|
1784
|
+
# stale and fold `"A"`. Only the shapes the folding layer itself materialises qualify here.
|
|
1785
|
+
#
|
|
1786
|
+
# This is only a property of the receiver expression; whether the grant is actually issued also needs
|
|
1787
|
+
# a `StructInstance` carrier and a body that survives the self-use scan, both decided in
|
|
1788
|
+
# {#build_user_method_body_scope}.
|
|
1789
|
+
def fold_safe_call_receiver?(call_node, receiver)
|
|
1790
|
+
return false if call_node.nil?
|
|
1791
|
+
|
|
1792
|
+
case call_node.receiver
|
|
1793
|
+
when Prism::CallNode then materialization_call?(call_node.receiver, receiver)
|
|
1794
|
+
when Prism::LocalVariableReadNode then scope.struct_fold_safe?(call_node.receiver.name)
|
|
1795
|
+
when nil, Prism::SelfNode then scope.struct_fold_safe?(:self)
|
|
1796
|
+
else false
|
|
1797
|
+
end
|
|
1798
|
+
end
|
|
1799
|
+
|
|
1800
|
+
# Issue #595 — the materialisation test lives ONCE, in
|
|
1801
|
+
# [`struct_materialization.rb`](method_dispatcher/struct_materialization.rb), because the direct
|
|
1802
|
+
# member-read gate ({MethodDispatcher::StructFolding}`#fresh_receiver?`) needs the identical answer;
|
|
1803
|
+
# see {MethodDispatcher::StructMaterialization.materialization_call?}. It resolves its own `.with`
|
|
1804
|
+
# guard through `Scope`, so neither consumer supplies a lookup and neither can answer differently.
|
|
1805
|
+
def materialization_call?(node, receiver)
|
|
1806
|
+
MethodDispatcher::StructMaterialization.materialization_call?(node, receiver, scope)
|
|
1807
|
+
end
|
|
1808
|
+
|
|
1343
1809
|
# Module-singleton call resolution (ADR-57 follow-up) — resolves `Foo.<name>` on a `Singleton[Foo]`
|
|
1344
1810
|
# receiver against `Foo`'s user-side singleton defs (`def self.x`, `def Foo.x`, a `class << self`
|
|
1345
1811
|
# body, or a `module_function` method) and re-types the body with the call's argument types bound.
|
|
@@ -1347,30 +1813,53 @@ module Rigor
|
|
|
1347
1813
|
# (`def self.via; helper(x); end`) re-enters this tier and resolves against the same singleton table
|
|
1348
1814
|
# — the symmetric counterpart of the instance-side ancestor walk.
|
|
1349
1815
|
#
|
|
1350
|
-
#
|
|
1351
|
-
#
|
|
1352
|
-
#
|
|
1353
|
-
def
|
|
1816
|
+
# Issue #731 — resolution walks the SUPERCLASS chain, so a class method a base class defines answers
|
|
1817
|
+
# on its subclasses (`class Plain < Base` with `def self.build` on `Base`: `Plain.build` was
|
|
1818
|
+
# `Dynamic[top]` while `Plain.new.inst` resolved on the same fixture). Superclasses only, which is the
|
|
1819
|
+
# whole shape of class-method inheritance — an `include`d module's `def self.x` is not callable on the
|
|
1820
|
+
# includer, and `extend M` is already folded into the extender's own singleton entries by
|
|
1821
|
+
# `ScopeIndexer`. A miss still degrades to `Dynamic[top]`, never a false resolution.
|
|
1822
|
+
#
|
|
1823
|
+
# The OWNER — not the receiver class — is what the overridable gate keys on, exactly as the instance
|
|
1824
|
+
# side does: adopting `Base`'s literal return is unsound when a subclass redefines the method, and
|
|
1825
|
+
# after this change the resolved body is routinely not the receiver's own.
|
|
1826
|
+
def try_singleton_method_inference(receiver, call_node, arg_types, method_name: call_node.name)
|
|
1354
1827
|
return nil unless receiver.is_a?(Type::Singleton)
|
|
1355
1828
|
|
|
1356
|
-
def_node =
|
|
1829
|
+
def_node, owner = resolve_singleton_def_with_owner(receiver.class_name, method_name)
|
|
1357
1830
|
return nil if def_node.nil?
|
|
1358
1831
|
|
|
1359
1832
|
result = infer_user_method_return(def_node, receiver, arg_types)
|
|
1360
1833
|
return result if result.nil?
|
|
1361
1834
|
|
|
1362
|
-
degrade_if_overridable(result,
|
|
1835
|
+
degrade_if_overridable(result, owner, method_name, :singleton)
|
|
1363
1836
|
rescue StandardError
|
|
1364
1837
|
nil
|
|
1365
1838
|
end
|
|
1366
1839
|
|
|
1840
|
+
# The singleton twin of {#resolve_user_def_with_owner}. Memoised in a bucket nested under the
|
|
1841
|
+
# SINGLETON def table's identity as well as the class-graph trio: `class_graph_buckets` keys on
|
|
1842
|
+
# `discovered_def_nodes` / `discovered_superclasses` / `discovered_includes`, and this walk reads a
|
|
1843
|
+
# fourth table those three do not imply. Keying on what the answer actually depends on is the #682
|
|
1844
|
+
# lesson — a memo that outlives its inputs serves one scope's answer to another.
|
|
1845
|
+
def resolve_singleton_def_with_owner(class_name, method_name)
|
|
1846
|
+
by_singleton = (class_graph_buckets[:singleton_def] ||= {}.compare_by_identity)
|
|
1847
|
+
cache = (by_singleton[scope.discovered_singleton_def_nodes] ||= {})
|
|
1848
|
+
table = (cache[class_name.to_s] ||= {})
|
|
1849
|
+
key = method_name.to_sym
|
|
1850
|
+
return table[key] if table.key?(key)
|
|
1851
|
+
|
|
1852
|
+
table[key] = scope.singleton_def_through_ancestors(class_name, method_name,
|
|
1853
|
+
name_memo: class_graph_buckets[:name])
|
|
1854
|
+
end
|
|
1855
|
+
|
|
1367
1856
|
# The project-side singleton-method band: a `Foo.bar` call resolved against a `class << …` / `def self.…`
|
|
1368
1857
|
# body the project itself wrote. Which of the two tiers applies is decided by the receiver carrier —
|
|
1369
1858
|
# `Singleton[Foo]` when the constant names a class or module, `Nominal[…]` when it holds an ordinary
|
|
1370
1859
|
# object (#320) — so the two are mutually exclusive and consulting both is one resolution attempt.
|
|
1371
|
-
def try_project_singleton_inference(receiver, call_node, arg_types)
|
|
1372
|
-
try_singleton_method_inference(receiver, call_node, arg_types) ||
|
|
1373
|
-
try_singleton_object_constant_inference(receiver, call_node, arg_types)
|
|
1860
|
+
def try_project_singleton_inference(receiver, call_node, arg_types, method_name: call_node.name)
|
|
1861
|
+
try_singleton_method_inference(receiver, call_node, arg_types, method_name: method_name) ||
|
|
1862
|
+
try_singleton_object_constant_inference(receiver, call_node, arg_types, method_name: method_name)
|
|
1374
1863
|
end
|
|
1375
1864
|
|
|
1376
1865
|
# #320 — resolves a call whose receiver is a constant holding an ordinary object with a `class << Const`
|
|
@@ -1378,10 +1867,10 @@ module Rigor
|
|
|
1378
1867
|
# that object, so the receiver carrier is passed through unchanged. Own-constant only, and only for a
|
|
1379
1868
|
# name the project actually recorded — a miss degrades to today's `Dynamic[top]`, never a false
|
|
1380
1869
|
# resolution. `Singleton` receivers never reach here: {#try_singleton_method_inference} owns them.
|
|
1381
|
-
def try_singleton_object_constant_inference(receiver, call_node, arg_types)
|
|
1870
|
+
def try_singleton_object_constant_inference(receiver, call_node, arg_types, method_name: call_node.name)
|
|
1382
1871
|
return nil unless receiver.is_a?(Type::Nominal)
|
|
1383
1872
|
|
|
1384
|
-
def_node = SingletonObjectConstant.def_node_for(call_node, receiver,
|
|
1873
|
+
def_node = SingletonObjectConstant.def_node_for(call_node, receiver, method_name, scope)
|
|
1385
1874
|
return nil if def_node.nil?
|
|
1386
1875
|
|
|
1387
1876
|
infer_user_method_return(def_node, receiver, arg_types)
|
|
@@ -1396,8 +1885,6 @@ module Rigor
|
|
|
1396
1885
|
# project-discovered class/module ends that branch. Cross-file: the chain is followed through
|
|
1397
1886
|
# `Scope#discovered_superclasses` / `#discovered_includes` / `#discovered_def_nodes`, which the runner
|
|
1398
1887
|
# seeds from the project-wide pre-pass. The walk is breadth-first, cycle-guarded, and node-count-capped.
|
|
1399
|
-
ANCESTOR_WALK_LIMIT = 100
|
|
1400
|
-
private_constant :ANCESTOR_WALK_LIMIT
|
|
1401
1888
|
|
|
1402
1889
|
CLASS_GRAPH_CACHE_KEY = :__rigor_class_graph_cache__
|
|
1403
1890
|
private_constant :CLASS_GRAPH_CACHE_KEY
|
|
@@ -1417,7 +1904,10 @@ module Rigor
|
|
|
1417
1904
|
store = (Thread.current[CLASS_GRAPH_CACHE_KEY] ||= {}.compare_by_identity)
|
|
1418
1905
|
by_def = (store[scope.discovered_def_nodes] ||= {}.compare_by_identity)
|
|
1419
1906
|
by_super = (by_def[scope.discovered_superclasses] ||= {}.compare_by_identity)
|
|
1420
|
-
|
|
1907
|
+
# `self_pure` is issue #525's grant scan (identity-keyed by def node); it belongs here because it
|
|
1908
|
+
# is a pure function of the same frozen index trio — the sibling resolver it walks reads nothing
|
|
1909
|
+
# else.
|
|
1910
|
+
by_super[scope.discovered_includes] ||= { name: {}, user_def: {}, self_pure: {}.compare_by_identity }
|
|
1421
1911
|
end
|
|
1422
1912
|
|
|
1423
1913
|
def resolve_user_def_through_ancestors(class_name, method_name)
|
|
@@ -1438,70 +1928,15 @@ module Rigor
|
|
|
1438
1928
|
table[key] = compute_user_def_with_owner(class_name, method_name)
|
|
1439
1929
|
end
|
|
1440
1930
|
|
|
1931
|
+
# ADR-24 slice 2 — the walk itself lives on `Scope` ({Scope#user_def_through_ancestors}); it reads
|
|
1932
|
+
# nothing but the frozen discovery index, and the `.with` guard in
|
|
1933
|
+
# {MethodDispatcher::StructMaterialization} needs the SAME answer without threading dispatcher state
|
|
1934
|
+
# through the dispatcher (#598 review). What stays here is the caching: the run-scoped
|
|
1935
|
+
# `(class_name, method_name)` memo above, plus the per-edge name bucket handed to the walk so a class
|
|
1936
|
+
# whose many methods are resolved pays each ancestor edge once.
|
|
1441
1937
|
def compute_user_def_with_owner(class_name, method_name)
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
visited = 0
|
|
1445
|
-
until queue.empty?
|
|
1446
|
-
current = queue.shift
|
|
1447
|
-
next if current.nil? || seen[current]
|
|
1448
|
-
|
|
1449
|
-
seen[current] = true
|
|
1450
|
-
visited += 1
|
|
1451
|
-
if visited > ANCESTOR_WALK_LIMIT
|
|
1452
|
-
BudgetTrace.hit(BudgetTrace::ANCESTOR_WALK_LIMIT)
|
|
1453
|
-
return [nil, nil]
|
|
1454
|
-
end
|
|
1455
|
-
|
|
1456
|
-
found = scope.user_def_for(current, method_name)
|
|
1457
|
-
return [found, current] if found
|
|
1458
|
-
|
|
1459
|
-
enqueue_ancestors(current, queue)
|
|
1460
|
-
end
|
|
1461
|
-
[nil, nil]
|
|
1462
|
-
end
|
|
1463
|
-
|
|
1464
|
-
# Pushes `current`'s direct ancestors onto the BFS queue: included / prepended modules first (Ruby
|
|
1465
|
-
# places mixins nearer than the superclass), then the superclass. Each as-written name is resolved
|
|
1466
|
-
# against `current`'s lexical nesting; names that resolve to no project class/module are dropped
|
|
1467
|
-
# (RBS-known / third-party ancestors).
|
|
1468
|
-
def enqueue_ancestors(current, queue)
|
|
1469
|
-
scope.includes_of(current).each do |raw|
|
|
1470
|
-
resolved = resolve_ancestor_class_name(current, raw)
|
|
1471
|
-
queue.push(resolved) if resolved
|
|
1472
|
-
end
|
|
1473
|
-
raw_super = scope.superclass_of(current)
|
|
1474
|
-
return if raw_super.nil?
|
|
1475
|
-
|
|
1476
|
-
resolved_super = resolve_ancestor_class_name(current, raw_super)
|
|
1477
|
-
queue.push(resolved_super) if resolved_super
|
|
1478
|
-
end
|
|
1479
|
-
|
|
1480
|
-
# Resolves a superclass name AS WRITTEN (`"Base"`, or a qualified `"A::B"`) to a project-discovered
|
|
1481
|
-
# class, following Ruby's `Module.nesting` constant lookup: try the raw name under each enclosing
|
|
1482
|
-
# namespace of the subclass, innermost first, then bare. Returns nil when no candidate names a
|
|
1483
|
-
# discovered user class (e.g. the superclass is an RBS-known or third-party class).
|
|
1484
|
-
def resolve_ancestor_class_name(subclass_qualified, raw_superclass)
|
|
1485
|
-
by_subclass = (class_graph_buckets[:name][subclass_qualified] ||= {})
|
|
1486
|
-
return by_subclass[raw_superclass] if by_subclass.key?(raw_superclass)
|
|
1487
|
-
|
|
1488
|
-
by_subclass[raw_superclass] =
|
|
1489
|
-
compute_ancestor_class_name(subclass_qualified, raw_superclass)
|
|
1490
|
-
end
|
|
1491
|
-
|
|
1492
|
-
def compute_ancestor_class_name(subclass_qualified, raw_superclass)
|
|
1493
|
-
segments = subclass_qualified.split("::")
|
|
1494
|
-
(segments.length - 1).downto(0) do |i|
|
|
1495
|
-
candidate = (segments[0, i] + [raw_superclass]).join("::")
|
|
1496
|
-
return candidate if known_user_class?(candidate)
|
|
1497
|
-
end
|
|
1498
|
-
nil
|
|
1499
|
-
end
|
|
1500
|
-
|
|
1501
|
-
def known_user_class?(name)
|
|
1502
|
-
scope.discovered_superclasses.key?(name) ||
|
|
1503
|
-
scope.discovered_def_nodes.key?(name) ||
|
|
1504
|
-
scope.discovered_includes.key?(name)
|
|
1938
|
+
scope.user_def_through_ancestors(class_name, method_name,
|
|
1939
|
+
name_memo: class_graph_buckets[:name])
|
|
1505
1940
|
end
|
|
1506
1941
|
|
|
1507
1942
|
# ADR-57 N5 — overridable-method adoption gate. A self-call resolved to a project `def` whose owner has
|
|
@@ -1604,6 +2039,12 @@ module Rigor
|
|
|
1604
2039
|
# `owner` — i.e. `candidate` is a subclass of an owner class or an includer of an owner module. Reuses
|
|
1605
2040
|
# the same BFS resolver the method-resolution ancestor walk uses, so name resolution (lexical nesting,
|
|
1606
2041
|
# RBS-known-ancestor pruning) is identical.
|
|
2042
|
+
# Delegates to the shared walk's edge step so this BFS and method resolution resolve ancestor names
|
|
2043
|
+
# identically, sharing the per-edge memo bucket.
|
|
2044
|
+
def enqueue_ancestors(current, queue)
|
|
2045
|
+
scope.enqueue_ancestors(current, queue, class_graph_buckets[:name])
|
|
2046
|
+
end
|
|
2047
|
+
|
|
1607
2048
|
def related_to_owner?(candidate, owner)
|
|
1608
2049
|
queue = []
|
|
1609
2050
|
enqueue_ancestors(candidate, queue)
|
|
@@ -1617,7 +2058,7 @@ module Rigor
|
|
|
1617
2058
|
|
|
1618
2059
|
seen[current] = true
|
|
1619
2060
|
visited += 1
|
|
1620
|
-
return false if visited > ANCESTOR_WALK_LIMIT
|
|
2061
|
+
return false if visited > Scope::ANCESTOR_WALK_LIMIT
|
|
1621
2062
|
|
|
1622
2063
|
enqueue_ancestors(current, queue)
|
|
1623
2064
|
end
|
|
@@ -1780,7 +2221,8 @@ module Rigor
|
|
|
1780
2221
|
# table, and the WD1 clamp flag so the helpers stay within the parameter-list budget. `def_node` is
|
|
1781
2222
|
# carried separately (it is the body owner, not call context).
|
|
1782
2223
|
RecursionContext = Data.define(
|
|
1783
|
-
:receiver, :arg_types, :plain_signature, :summaries, :would_have_been_guarded
|
|
2224
|
+
:receiver, :arg_types, :plain_signature, :summaries, :would_have_been_guarded,
|
|
2225
|
+
:self_fold_safe
|
|
1784
2226
|
)
|
|
1785
2227
|
private_constant :RecursionContext
|
|
1786
2228
|
|
|
@@ -1801,10 +2243,16 @@ module Rigor
|
|
|
1801
2243
|
RECURSION_VALUE_SIZE_CAP = 64
|
|
1802
2244
|
private_constant :RECURSION_VALUE_SIZE_CAP
|
|
1803
2245
|
|
|
1804
|
-
|
|
2246
|
+
# `self_fold_safe` (issue #525) is the caller's statement that its RECEIVER EXPRESSION was foldable;
|
|
2247
|
+
# {#build_user_method_body_scope} turns it into the body scope's `:self` sentinel when the carrier and
|
|
2248
|
+
# the body both qualify. It does NOT need its own memo-key slot here — the bit is observable on the
|
|
2249
|
+
# built `body_scope`, which is where every downstream consumer (the memo key, the recursion context)
|
|
2250
|
+
# reads it from, so the two can never disagree.
|
|
2251
|
+
def infer_user_method_return(def_node, receiver, arg_types, self_fold_safe: false)
|
|
1805
2252
|
return nil if def_node.body.nil?
|
|
1806
2253
|
|
|
1807
|
-
body_scope = build_user_method_body_scope(def_node, receiver, arg_types
|
|
2254
|
+
body_scope = build_user_method_body_scope(def_node, receiver, arg_types,
|
|
2255
|
+
self_fold_safe: self_fold_safe)
|
|
1808
2256
|
return nil if body_scope.nil?
|
|
1809
2257
|
|
|
1810
2258
|
# Recursion-guard signature. Keyed on `(receiver, method)` only — NOT the argument types. ADR-24 WD5:
|
|
@@ -1874,8 +2322,14 @@ module Rigor
|
|
|
1874
2322
|
def consult_and_store_return_memo(def_node, body_scope, stack, summaries,
|
|
1875
2323
|
receiver, arg_types, plain_signature)
|
|
1876
2324
|
per_def = (return_memo_bucket[def_node] ||= {})
|
|
2325
|
+
# Issue #525 — the `:self` fold-safety grant is a THIRD call-site-varying dimension: the same
|
|
2326
|
+
# `(receiver, arg_types)` body returns a folded member type when the caller's receiver expression
|
|
2327
|
+
# was foldable and `Dynamic[top]` when it was not. Without it in the key the first call site to
|
|
2328
|
+
# reach a def would poison every later one with the other polarity. It is read off the body scope
|
|
2329
|
+
# rather than passed in, so the key cannot drift from the scope that produced the result.
|
|
1877
2330
|
memo_key = [receiver.describe(:short),
|
|
1878
|
-
arg_types.map { |type| type.describe(:short) }
|
|
2331
|
+
arg_types.map { |type| type.describe(:short) },
|
|
2332
|
+
body_scope.struct_fold_safe?(:self)]
|
|
1879
2333
|
if (entry = per_def[memo_key])
|
|
1880
2334
|
BudgetTrace.hit(BudgetTrace::MEMO_HITS)
|
|
1881
2335
|
Analysis::DependencyRecorder.replay(entry.read_set) if Analysis::DependencyRecorder.active?
|
|
@@ -1977,7 +2431,11 @@ module Rigor
|
|
|
1977
2431
|
|
|
1978
2432
|
context = RecursionContext.new(
|
|
1979
2433
|
receiver: receiver, arg_types: arg_types, plain_signature: plain_signature,
|
|
1980
|
-
summaries: summaries, would_have_been_guarded: would_have_been_guarded
|
|
2434
|
+
summaries: summaries, would_have_been_guarded: would_have_been_guarded,
|
|
2435
|
+
# Issue #525 — read off the built scope so the bot-collapse retry rebuilds a widened scope with
|
|
2436
|
+
# the SAME `:self` polarity the first attempt used; a retry that silently dropped the grant would
|
|
2437
|
+
# return a different type for the same call.
|
|
2438
|
+
self_fold_safe: body_scope.struct_fold_safe?(:self)
|
|
1981
2439
|
)
|
|
1982
2440
|
evaluate_guarded_user_method_body(def_node, body_scope, stack, signature, context)
|
|
1983
2441
|
end
|
|
@@ -2222,9 +2680,9 @@ module Rigor
|
|
|
2222
2680
|
# `fixpoint_user_method_return`: call-site argument narrowing can prune a recursive method's base
|
|
2223
2681
|
# case, and widening restores the declared-type view under which the base case is reachable. Returns
|
|
2224
2682
|
# `nil` when the parameter shape is not inferable (mirrors `build_user_method_body_scope`).
|
|
2225
|
-
def widened_user_method_body_scope(def_node, receiver, arg_types)
|
|
2683
|
+
def widened_user_method_body_scope(def_node, receiver, arg_types, self_fold_safe: false)
|
|
2226
2684
|
widened_args = arg_types.map { |arg_type| widen_value_pinned(arg_type) }
|
|
2227
|
-
build_user_method_body_scope(def_node, receiver, widened_args)
|
|
2685
|
+
build_user_method_body_scope(def_node, receiver, widened_args, self_fold_safe: self_fold_safe)
|
|
2228
2686
|
end
|
|
2229
2687
|
|
|
2230
2688
|
# ADR-55 slice 2 bot-collapse resolution (2026-06-11). Called when a fixpoint iteration computed `bot`
|
|
@@ -2244,7 +2702,8 @@ module Rigor
|
|
|
2244
2702
|
# divergence — `spin`).
|
|
2245
2703
|
def resolve_bot_collapse(def_node, context, widened:)
|
|
2246
2704
|
unless widened
|
|
2247
|
-
widened_scope = widened_user_method_body_scope(def_node, context.receiver, context.arg_types
|
|
2705
|
+
widened_scope = widened_user_method_body_scope(def_node, context.receiver, context.arg_types,
|
|
2706
|
+
self_fold_safe: context.self_fold_safe)
|
|
2248
2707
|
return fixpoint_user_method_return(def_node, widened_scope, context, widened: true) unless widened_scope.nil?
|
|
2249
2708
|
end
|
|
2250
2709
|
|
|
@@ -2382,20 +2841,23 @@ module Rigor
|
|
|
2382
2841
|
|
|
2383
2842
|
# Builds the body scope for a user-defined instance method call: a fresh `Scope` with `self_type` set
|
|
2384
2843
|
# to the receiver's nominal type, the project-wide accumulators inherited (so the body sees the same
|
|
2385
|
-
# `discovered_classes` / `class_ivars` / etc. the caller does), and
|
|
2386
|
-
#
|
|
2387
|
-
#
|
|
2388
|
-
|
|
2844
|
+
# `discovered_classes` / `class_ivars` / etc. the caller does), and the call's `arg_types` bound to
|
|
2845
|
+
# the parameters. Returns nil when the binding is truly ambiguous (arity mismatch, trailing `post`
|
|
2846
|
+
# params, `...` forwarding).
|
|
2847
|
+
#
|
|
2848
|
+
# Issue #524 — the binder is per-PARAMETER, not per-signature. The first iteration declined any def
|
|
2849
|
+
# with an optional / rest / keyword / block parameter outright, so ONE `options = {}` default zeroed
|
|
2850
|
+
# out an otherwise-inferable return at every call site (the widest engine lever the 2026-09-01 sweep
|
|
2851
|
+
# measured, verified on ten targets). Now: requireds bind by index; supplied optionals by index and
|
|
2852
|
+
# unsupplied ones from a LITERAL default (else `Dynamic`); `*rest` collects the leftover positionals
|
|
2853
|
+
# as a Tuple; keywords bind by name from a trailing keyword-hash shape, falling back to their literal
|
|
2854
|
+
# default — or `Dynamic` when the shape is open or absent; `**kwrest`, `&block`, and destructured
|
|
2855
|
+
# positionals bind `Dynamic`. Binding `Dynamic` is monotone-safe (wider in → wider out), so the only
|
|
2856
|
+
# declines left are the shapes where positional CORRESPONDENCE itself is unknowable.
|
|
2857
|
+
def build_user_method_body_scope(def_node, receiver, arg_types, self_fold_safe: false)
|
|
2389
2858
|
params = def_node.parameters
|
|
2390
|
-
|
|
2391
|
-
return nil
|
|
2392
|
-
return nil unless required.size == arg_types.size
|
|
2393
|
-
|
|
2394
|
-
# Bind required positionals by index. The body scope starts from an empty fact store and narrowing
|
|
2395
|
-
# set, so `with_local`'s fact / narrowing invalidations would be no-ops here — build the locals
|
|
2396
|
-
# table directly (matching `with_local`'s `name.to_sym` key).
|
|
2397
|
-
locals = {}
|
|
2398
|
-
required.each_with_index { |param, index| locals[param.name.to_sym] = arg_types[index] }
|
|
2859
|
+
locals = bind_params_from_call_types(params, arg_types)
|
|
2860
|
+
return nil if locals.nil?
|
|
2399
2861
|
|
|
2400
2862
|
# Construct the body scope in a SINGLE allocation — the previous `Scope.empty.with_*.with_*…` chain
|
|
2401
2863
|
# allocated a fresh frozen Scope per field, run per user-method-call inference (ADR-44). The
|
|
@@ -2405,12 +2867,179 @@ module Rigor
|
|
|
2405
2867
|
environment: scope.environment,
|
|
2406
2868
|
locals: locals.freeze,
|
|
2407
2869
|
self_type: receiver,
|
|
2870
|
+
# Issue #681 — the chain the walk recorded as it entered `def_node`'s own declaration. Without it
|
|
2871
|
+
# this scope carried a self type alone, and `Reflection.lexical_nesting_chain` fell back to peeling
|
|
2872
|
+
# `receiver`'s qualified name — which cannot tell a compact `class Admin::Maker` from the nested
|
|
2873
|
+
# spelling, and for an INHERITED body peels the subclass rather than the declaration that owns it.
|
|
2874
|
+
# The same `Post.new` then typed one way on the line that writes it and another through this
|
|
2875
|
+
# re-walk. Issue #716 — `[]` for a top-level def: that IS Ruby's `Module.nesting` there, and it
|
|
2876
|
+
# sends the body's constants to the top level instead of to the caller's namespace.
|
|
2877
|
+
lexical_nesting: recorded_def_nesting(def_node),
|
|
2408
2878
|
discovery: scope.discovery,
|
|
2409
|
-
struct_fold_safe_locals:
|
|
2879
|
+
struct_fold_safe_locals: body_fold_safe_locals(def_node, receiver, self_fold_safe),
|
|
2410
2880
|
dynamic_origins: scope.dynamic_origins
|
|
2411
2881
|
)
|
|
2412
2882
|
end
|
|
2413
2883
|
|
|
2884
|
+
# Issue #681 / #707 — the ONE place in `lib/` that answers "what `Module.nesting` was recorded for this
|
|
2885
|
+
# def node". `Scope::DiscoveryIndex#discovered_def_nestings` is the owner; the resolver memo is that
|
|
2886
|
+
# table's REHYDRATION for the one population it cannot hold — a def served from an ADR-85 seed bundle,
|
|
2887
|
+
# whose node {Inference::DefNodeResolver} mints from its own parse, so no table keyed by the identities
|
|
2888
|
+
# the analyzer's own walks produce can contain it.
|
|
2889
|
+
#
|
|
2890
|
+
# The order is safe on OBJECT PROVENANCE, not on any file-level split: the resolver parses separately
|
|
2891
|
+
# and both tables are `compare_by_identity`, so no node is a key in both, and the content-digest gate
|
|
2892
|
+
# makes the two agree where they answer for the same def through different objects. A file can be
|
|
2893
|
+
# bundle-served and walked live in the SAME run (an unchanged file re-analysed as a dependent), which is
|
|
2894
|
+
# exactly why the guarantee has to rest on the parse rather than on the file — see
|
|
2895
|
+
# {Inference::DefNodeResolver.rehydrated_nesting}. Routing both through one reader is what keeps a cold
|
|
2896
|
+
# run and an `--incremental` recheck answering the same constant for the same body; the rehydration half
|
|
2897
|
+
# is populated only inside `DefNodeResolver.with_run`, and outside it this falls back to the peel.
|
|
2898
|
+
def recorded_def_nesting(def_node)
|
|
2899
|
+
scope.discovery.discovered_def_nestings[def_node] || DefNodeResolver.rehydrated_nesting(def_node)
|
|
2900
|
+
end
|
|
2901
|
+
|
|
2902
|
+
# The body scope's fold-safe set: the body's own struct locals, plus issue #525's `:self` sentinel
|
|
2903
|
+
# when the caller's receiver expression was foldable AND the carrier is a `StructInstance` AND the
|
|
2904
|
+
# body's every use of `self` is a pure read (no member setter, no escape, no unrecognised self-call —
|
|
2905
|
+
# see {Inference::StructFoldSafety.self_fold_safe_body?}). All three are required: the first is the
|
|
2906
|
+
# caller's evidence that the map is current on entry, the last two that the body keeps it current.
|
|
2907
|
+
def body_fold_safe_locals(def_node, receiver, self_fold_safe)
|
|
2908
|
+
locals = struct_fold_safe_locals_for(def_node.body)
|
|
2909
|
+
return locals unless self_fold_safe && receiver.is_a?(Type::StructInstance)
|
|
2910
|
+
return locals unless self_fold_safe_grant?(def_node, receiver)
|
|
2911
|
+
|
|
2912
|
+
locals + [:self]
|
|
2913
|
+
end
|
|
2914
|
+
|
|
2915
|
+
# The memoised entry point to the grant scan. Without the memo the walk re-runs on every
|
|
2916
|
+
# granted-CANDIDATE call, return-memo HITS included — the body scope is built before the memo is
|
|
2917
|
+
# consulted, so a hot call site pays the scan every time. Only the OUTERMOST scan is memoised: an
|
|
2918
|
+
# inner answer can rest on the cycle guard's `seen` set, which is not part of the key.
|
|
2919
|
+
def self_fold_safe_grant?(def_node, receiver)
|
|
2920
|
+
per_def = (class_graph_buckets[:self_pure][def_node] ||= {})
|
|
2921
|
+
key = [receiver.class_name, receiver.member_names]
|
|
2922
|
+
return per_def[key] if per_def.key?(key)
|
|
2923
|
+
|
|
2924
|
+
per_def[key] = self_fold_safe_body?(def_node.body, receiver, [def_node])
|
|
2925
|
+
end
|
|
2926
|
+
|
|
2927
|
+
# Whether `body`'s every use of `self` is a pure read, resolving each unrecognised self-call against
|
|
2928
|
+
# the receiver's own class so a body that DELEGATES can still hold the grant: `def outer; shout; end`
|
|
2929
|
+
# keeps it because `shout` reads a member, while `def go; reset!; text; end` loses it because `reset!`
|
|
2930
|
+
# writes one. `seen` breaks a mutual-call cycle by refusing (the conservative direction) and
|
|
2931
|
+
# `SELF_PURE_DEPTH` bounds the walk — an unresolvable name (`puts`, `raise`, a method from an RBS-only
|
|
2932
|
+
# ancestor) refuses too, so every answer this returns is backed by a body actually examined.
|
|
2933
|
+
SELF_PURE_DEPTH = 4
|
|
2934
|
+
private_constant :SELF_PURE_DEPTH
|
|
2935
|
+
|
|
2936
|
+
def self_fold_safe_body?(body, receiver, seen)
|
|
2937
|
+
StructFoldSafety.self_fold_safe_body?(body, receiver.member_names) do |name|
|
|
2938
|
+
next false if seen.size > SELF_PURE_DEPTH
|
|
2939
|
+
|
|
2940
|
+
sibling, = resolve_user_def_with_owner(receiver.class_name, name)
|
|
2941
|
+
next false if sibling.nil? || sibling.body.nil? || seen.include?(sibling)
|
|
2942
|
+
|
|
2943
|
+
self_fold_safe_body?(sibling.body, receiver, seen + [sibling])
|
|
2944
|
+
end
|
|
2945
|
+
end
|
|
2946
|
+
|
|
2947
|
+
# The locals table for the body scope, or nil to decline. Keys match `with_local`'s `name.to_sym`.
|
|
2948
|
+
# The body scope starts from an empty fact store and narrowing set, so `with_local`'s fact /
|
|
2949
|
+
# narrowing invalidations would be no-ops here — the table is built directly.
|
|
2950
|
+
def bind_params_from_call_types(params, arg_types)
|
|
2951
|
+
return arg_types.empty? ? {} : nil if params.nil?
|
|
2952
|
+
return nil unless bindable_param_shape?(params)
|
|
2953
|
+
|
|
2954
|
+
positional = arg_types.dup
|
|
2955
|
+
kw_shape = positional.pop if takes_keywords?(params) && positional.last.is_a?(Type::HashShape)
|
|
2956
|
+
locals = bind_positional_params(params, positional)
|
|
2957
|
+
return nil if locals.nil?
|
|
2958
|
+
|
|
2959
|
+
bind_keyword_params(params, kw_shape, locals)
|
|
2960
|
+
locals[params.keyword_rest.name.to_sym] = dynamic_top if params.keyword_rest&.name
|
|
2961
|
+
locals[params.block.name.to_sym] = dynamic_top if params.block&.name
|
|
2962
|
+
locals
|
|
2963
|
+
end
|
|
2964
|
+
|
|
2965
|
+
# Trailing required positionals after a rest (`def f(a, *m, z)`) shift the correspondence; `...`
|
|
2966
|
+
# arrives as the keyword_rest slot. Both stay declined — correspondence, not width, is the issue.
|
|
2967
|
+
def bindable_param_shape?(params)
|
|
2968
|
+
params.is_a?(Prism::ParametersNode) &&
|
|
2969
|
+
params.posts.empty? &&
|
|
2970
|
+
!params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
|
|
2971
|
+
end
|
|
2972
|
+
|
|
2973
|
+
def takes_keywords?(params)
|
|
2974
|
+
params.keywords.any? || !params.keyword_rest.nil?
|
|
2975
|
+
end
|
|
2976
|
+
|
|
2977
|
+
def bind_positional_params(params, positional)
|
|
2978
|
+
requireds = params.requireds
|
|
2979
|
+
optionals = params.optionals
|
|
2980
|
+
return nil if positional.size < requireds.size
|
|
2981
|
+
return nil if params.rest.nil? && positional.size > requireds.size + optionals.size
|
|
2982
|
+
|
|
2983
|
+
locals = {}
|
|
2984
|
+
requireds.each_with_index { |param, index| bind_positional_param(locals, param, positional[index]) }
|
|
2985
|
+
optionals.each_with_index do |param, index|
|
|
2986
|
+
supplied = positional[requireds.size + index]
|
|
2987
|
+
locals[param.name.to_sym] = supplied || literal_default_type(param.value)
|
|
2988
|
+
end
|
|
2989
|
+
bind_rest_param(params, positional, locals)
|
|
2990
|
+
locals
|
|
2991
|
+
end
|
|
2992
|
+
|
|
2993
|
+
def bind_rest_param(params, positional, locals)
|
|
2994
|
+
rest_name = params.rest&.name
|
|
2995
|
+
return if rest_name.nil?
|
|
2996
|
+
|
|
2997
|
+
leftover = positional[(params.requireds.size + params.optionals.size)..] || []
|
|
2998
|
+
locals[rest_name.to_sym] = Type::Combinator.tuple_of(*leftover)
|
|
2999
|
+
end
|
|
3000
|
+
|
|
3001
|
+
# A destructured positional (`def f((a, b))`) consumes one argument slot but binds its leaf names
|
|
3002
|
+
# `Dynamic` — element correspondence through the destructure is a later slice.
|
|
3003
|
+
def bind_positional_param(locals, param, arg_type)
|
|
3004
|
+
if param.is_a?(Prism::MultiTargetNode)
|
|
3005
|
+
Destructure.target_names(param).each { |name| locals[name.to_sym] = dynamic_top }
|
|
3006
|
+
else
|
|
3007
|
+
locals[param.name.to_sym] = arg_type
|
|
3008
|
+
end
|
|
3009
|
+
end
|
|
3010
|
+
|
|
3011
|
+
def bind_keyword_params(params, kw_shape, locals)
|
|
3012
|
+
open_shape = kw_shape && kw_shape.extra_keys == :open
|
|
3013
|
+
params.keywords.each do |param|
|
|
3014
|
+
name = param.name.to_s.delete_suffix(":").to_sym
|
|
3015
|
+
supplied = kw_shape&.pairs&.[](name)
|
|
3016
|
+
locals[name] = supplied ||
|
|
3017
|
+
(open_shape ? dynamic_top : keyword_default_type(param))
|
|
3018
|
+
end
|
|
3019
|
+
end
|
|
3020
|
+
|
|
3021
|
+
def keyword_default_type(param)
|
|
3022
|
+
param.respond_to?(:value) && param.value ? literal_default_type(param.value) : dynamic_top
|
|
3023
|
+
end
|
|
3024
|
+
|
|
3025
|
+
# A default expression contributes its type only when it is lexically scope-free — a scalar literal
|
|
3026
|
+
# or an EMPTY collection literal (`options = {}` is the dominant Rails idiom). Anything that could
|
|
3027
|
+
# read the def's own lexical scope binds `Dynamic` instead of being mis-typed in the caller's scope.
|
|
3028
|
+
LITERAL_DEFAULT_NODES = [
|
|
3029
|
+
Prism::IntegerNode, Prism::FloatNode, Prism::StringNode, Prism::SymbolNode,
|
|
3030
|
+
Prism::TrueNode, Prism::FalseNode, Prism::NilNode
|
|
3031
|
+
].freeze
|
|
3032
|
+
private_constant :LITERAL_DEFAULT_NODES
|
|
3033
|
+
|
|
3034
|
+
def literal_default_type(value_node)
|
|
3035
|
+
return dynamic_top if value_node.nil?
|
|
3036
|
+
return type_of(value_node) if LITERAL_DEFAULT_NODES.any? { |klass| value_node.is_a?(klass) }
|
|
3037
|
+
return type_of(value_node) if value_node.is_a?(Prism::ArrayNode) && value_node.elements.empty?
|
|
3038
|
+
return type_of(value_node) if value_node.is_a?(Prism::HashNode) && value_node.elements.empty?
|
|
3039
|
+
|
|
3040
|
+
dynamic_top
|
|
3041
|
+
end
|
|
3042
|
+
|
|
2414
3043
|
# ADR-48 Struct slice 3 — the fold-safe-local set for a method body (runs only on a return-memo miss,
|
|
2415
3044
|
# so the per-call cost is bounded — measured perf-neutral). Struct member layouts of constant
|
|
2416
3045
|
# receivers are resolved through the discovery side-table the body scope inherits.
|
|
@@ -2421,19 +3050,6 @@ module Rigor
|
|
|
2421
3050
|
)
|
|
2422
3051
|
end
|
|
2423
3052
|
|
|
2424
|
-
# First iteration accepts only required positional parameters: `def foo(a, b, c)`. Optionals, rest,
|
|
2425
|
-
# keyword params, and block params disqualify the method from inference (the caller observes
|
|
2426
|
-
# `Dynamic[Top]` instead).
|
|
2427
|
-
def user_method_param_shape_simple?(params)
|
|
2428
|
-
return false unless params.is_a?(Prism::ParametersNode)
|
|
2429
|
-
|
|
2430
|
-
params.optionals.empty? &&
|
|
2431
|
-
params.rest.nil? &&
|
|
2432
|
-
params.keywords.empty? &&
|
|
2433
|
-
params.keyword_rest.nil? &&
|
|
2434
|
-
params.block.nil?
|
|
2435
|
-
end
|
|
2436
|
-
|
|
2437
3053
|
# Slice A-engine. Implicit-self calls (no `node.receiver`) adopt the surrounding scope's `self_type`
|
|
2438
3054
|
# as their receiver so calls like `attr_reader_method_name` or `private_helper(...)` inside an
|
|
2439
3055
|
# instance method dispatch against the enclosing class. Slice 7 phase 10 — when `self_type` is nil
|
|
@@ -2533,11 +3149,187 @@ module Rigor
|
|
|
2533
3149
|
result || dynamic_top
|
|
2534
3150
|
end
|
|
2535
3151
|
|
|
3152
|
+
# The block body's value type — what the dispatcher binds a generic block-return variable to.
|
|
3153
|
+
#
|
|
3154
|
+
# Issue #533 item 9: this pass used to type only the body's LAST statement, in the block's ENTRY scope.
|
|
3155
|
+
# A tail reading a name an earlier statement of the same body binds (`m.synchronize do v = 42; v end`)
|
|
3156
|
+
# therefore never saw the binding and fell through `local_read` to `Dynamic[top]`, while the main pass
|
|
3157
|
+
# — which threads scope statement by statement through `StatementEvaluator` — held `42` for the very
|
|
3158
|
+
# same node. {#threaded_block_body_type} closes that gap by reusing the main pass's evaluator; a decline
|
|
3159
|
+
# keeps the tail-only answer verbatim.
|
|
2536
3160
|
def type_block_body(block_node, block_scope)
|
|
2537
3161
|
body = block_node.body
|
|
2538
3162
|
return Type::Combinator.constant_of(nil) if body.nil?
|
|
2539
3163
|
|
|
2540
|
-
block_scope.type_of(body)
|
|
3164
|
+
threaded_block_body_type(body, block_scope) || block_scope.type_of(body)
|
|
3165
|
+
end
|
|
3166
|
+
|
|
3167
|
+
# Re-typing the whole body would be wrong to do unconditionally: this path runs for EVERY block-bearing
|
|
3168
|
+
# call, and the statements ahead of the tail are pure cost whenever the tail does not depend on them.
|
|
3169
|
+
# Three declines keep that cost where the defect actually is, each falling back to the tail-only path:
|
|
3170
|
+
#
|
|
3171
|
+
# - a `nil` or non-`StatementsNode` body (`do … rescue … end` parses as a `BeginNode`) and a
|
|
3172
|
+
# single-statement body — the overwhelming majority of blocks — pay nothing at all;
|
|
3173
|
+
# - a multi-statement body pays one DFS over its non-tail statements, and threads only when the tail
|
|
3174
|
+
# READS a variable name one of them WRITES or MUTATES IN PLACE ({#tail_depends_on_body_binding?}).
|
|
3175
|
+
# That is exactly the diagnosed shape, so a block whose tail does not consume the body's own
|
|
3176
|
+
# bindings keeps today's type;
|
|
3177
|
+
# - the fold is not re-entrant. `StatementEvaluator#eval_call` already evaluates each nested block body
|
|
3178
|
+
# once, plus up to three more times under the ADR-56 `BodyFixpoint` when the block rebinds a captured
|
|
3179
|
+
# local, so a fold nested inside a fold would multiply that work per block-nesting level. Inside a
|
|
3180
|
+
# threaded body a nested block-bearing call reverts to the tail-only path — a wider answer in a rare
|
|
3181
|
+
# shape, never a new false positive.
|
|
3182
|
+
#
|
|
3183
|
+
# ADR-56 interaction: the fold cannot double-apply or fight the captured-local write-back. That
|
|
3184
|
+
# write-back is `StatementEvaluator#write_back_block_captures`, computed from the CALLER's scope into
|
|
3185
|
+
# the caller's continuation; this pass only derives a value type from a throwaway block scope and
|
|
3186
|
+
# discards the exit scope, exactly as the tail-only path did.
|
|
3187
|
+
#
|
|
3188
|
+
# Any failure inside the fold falls back to the tail-only answer rather than propagating: the enclosing
|
|
3189
|
+
# `block_return_type_for` rescue would otherwise report "no block" to the dispatcher, which is a much
|
|
3190
|
+
# larger regression than a wide block return.
|
|
3191
|
+
def threaded_block_body_type(body, block_scope)
|
|
3192
|
+
return nil unless body.is_a?(Prism::StatementsNode)
|
|
3193
|
+
|
|
3194
|
+
statements = body.body
|
|
3195
|
+
return nil if statements.size < 2
|
|
3196
|
+
return nil if block_body_threading_suppressed?
|
|
3197
|
+
return nil unless tail_depends_on_body_binding?(statements)
|
|
3198
|
+
|
|
3199
|
+
without_block_body_threading { block_scope.evaluate(body).first }
|
|
3200
|
+
rescue StandardError
|
|
3201
|
+
nil
|
|
3202
|
+
end
|
|
3203
|
+
|
|
3204
|
+
# Suppression flag for {#threaded_block_body_type} — set while a fold is running (so a fold never nests)
|
|
3205
|
+
# and while the per-element Tuple fold is over its arity cap. Thread-local because block typing is
|
|
3206
|
+
# re-entrant within one thread and the fork-pool workers each own their own.
|
|
3207
|
+
THREADED_BLOCK_BODY_KEY = :__rigor_threaded_block_body__
|
|
3208
|
+
private_constant :THREADED_BLOCK_BODY_KEY
|
|
3209
|
+
|
|
3210
|
+
def block_body_threading_suppressed?
|
|
3211
|
+
Thread.current[THREADED_BLOCK_BODY_KEY] ? true : false
|
|
3212
|
+
end
|
|
3213
|
+
|
|
3214
|
+
# Runs the block with the scope-threading fold suppressed, restoring the previous state (not clearing
|
|
3215
|
+
# it) on the way out, so nesting a suppressed region inside another cannot re-enable threading.
|
|
3216
|
+
def without_block_body_threading
|
|
3217
|
+
previous = Thread.current[THREADED_BLOCK_BODY_KEY]
|
|
3218
|
+
Thread.current[THREADED_BLOCK_BODY_KEY] = true
|
|
3219
|
+
begin
|
|
3220
|
+
yield
|
|
3221
|
+
ensure
|
|
3222
|
+
Thread.current[THREADED_BLOCK_BODY_KEY] = previous
|
|
3223
|
+
end
|
|
3224
|
+
end
|
|
3225
|
+
|
|
3226
|
+
# Every variable-write form whose name `StatementEvaluator` threads into the following statement's
|
|
3227
|
+
# scope, including the multi-assign / pattern targets that appear under a `MultiWriteNode`.
|
|
3228
|
+
VARIABLE_WRITE_NODES = Set[
|
|
3229
|
+
Prism::LocalVariableWriteNode, Prism::LocalVariableOperatorWriteNode,
|
|
3230
|
+
Prism::LocalVariableOrWriteNode, Prism::LocalVariableAndWriteNode,
|
|
3231
|
+
Prism::LocalVariableTargetNode,
|
|
3232
|
+
Prism::InstanceVariableWriteNode, Prism::InstanceVariableOperatorWriteNode,
|
|
3233
|
+
Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode,
|
|
3234
|
+
Prism::InstanceVariableTargetNode,
|
|
3235
|
+
Prism::ClassVariableWriteNode, Prism::ClassVariableOperatorWriteNode,
|
|
3236
|
+
Prism::ClassVariableOrWriteNode, Prism::ClassVariableAndWriteNode,
|
|
3237
|
+
Prism::ClassVariableTargetNode,
|
|
3238
|
+
Prism::GlobalVariableWriteNode, Prism::GlobalVariableOperatorWriteNode,
|
|
3239
|
+
Prism::GlobalVariableOrWriteNode, Prism::GlobalVariableAndWriteNode,
|
|
3240
|
+
Prism::GlobalVariableTargetNode
|
|
3241
|
+
].freeze
|
|
3242
|
+
private_constant :VARIABLE_WRITE_NODES
|
|
3243
|
+
|
|
3244
|
+
# Every node that OBSERVES a variable binding: the plain reads plus the compound writes, which read
|
|
3245
|
+
# their target before rebinding it (`v += 1` in the tail depends on an earlier `v = 0`).
|
|
3246
|
+
VARIABLE_READ_NODES = (
|
|
3247
|
+
VARIABLE_WRITE_NODES | [
|
|
3248
|
+
Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode,
|
|
3249
|
+
Prism::ClassVariableReadNode, Prism::GlobalVariableReadNode
|
|
3250
|
+
]
|
|
3251
|
+
).freeze
|
|
3252
|
+
private_constant :VARIABLE_READ_NODES
|
|
3253
|
+
|
|
3254
|
+
# A `next` / `break` that leaves THIS block carries a value the fold cannot see: `evaluate(body).first`
|
|
3255
|
+
# is the fall-through value only, and no next-value join into the block return exists (`type_of_jump`
|
|
3256
|
+
# types both as `Bot`). So `m.synchronize do next 5 if flag; v = 42; v end` really can answer 5 at
|
|
3257
|
+
# runtime, and threading would type it `42`. Both forms escape with a value — `next v` is the block's
|
|
3258
|
+
# value for that yield, `break v` is the yielding CALL's value — so both must decline.
|
|
3259
|
+
JUMP_NODES = Set[Prism::NextNode, Prism::BreakNode].freeze
|
|
3260
|
+
private_constant :JUMP_NODES
|
|
3261
|
+
|
|
3262
|
+
# Constructs that RETARGET a `next` / `break` nested inside them, so a jump below one of these says
|
|
3263
|
+
# nothing about our block's value and must not trigger the decline. A nested `BlockNode` / `LambdaNode`
|
|
3264
|
+
# is the jump's own block (`do xs.each { next 1 }; v = 42; v end` threads soundly — the inner `next`
|
|
3265
|
+
# ends the inner iteration); a loop consumes both forms (`while … next 5 … end` continues the loop);
|
|
3266
|
+
# a `DefNode` body is a different method entirely.
|
|
3267
|
+
JUMP_BOUNDARY_NODES = Set[
|
|
3268
|
+
Prism::BlockNode, Prism::LambdaNode, Prism::DefNode,
|
|
3269
|
+
Prism::WhileNode, Prism::UntilNode, Prism::ForNode
|
|
3270
|
+
].freeze
|
|
3271
|
+
private_constant :JUMP_BOUNDARY_NODES
|
|
3272
|
+
|
|
3273
|
+
# True when the tail statement observes a variable name one of the earlier statements binds OR mutates
|
|
3274
|
+
# in place — the two ways threading the scope through the body can change the tail's type — AND no
|
|
3275
|
+
# earlier statement can jump out of the block with a value. The name sets are compared sigil-and-all
|
|
3276
|
+
# across kinds, so the answer over-approximates (an `@x` write plus an `x` read threads needlessly);
|
|
3277
|
+
# over-approximating only spends the fold, it never changes an answer.
|
|
3278
|
+
#
|
|
3279
|
+
# The in-place half is issue #587. `outer = []; m.synchronize do outer.push(1); outer end` binds no
|
|
3280
|
+
# variable in its prefix — `push` is a call, not a write node — so a write-only scan declined and the
|
|
3281
|
+
# tail kept the entry scope's empty `Tuple[]`, a wrong-precise answer (the runtime value is `[1]`) that
|
|
3282
|
+
# hands downstream rules a provably-empty array. Threading is the fix, not a cost: `StatementEvaluator`
|
|
3283
|
+
# runs `MutationWidening.widen_after_call` on the `push`, so the threaded tail reads the widened
|
|
3284
|
+
# `Array[…]`. A call therefore contributes every variable its receiver can evaluate to
|
|
3285
|
+
# ({ReceiverAlias.candidates} — the ternary-selected receiver of issue #277 included) whenever its name
|
|
3286
|
+
# is one the widening responds to ({MutationWidening::SHAPE_MUTATORS}); keying on the widening's own
|
|
3287
|
+
# tables is what keeps "the scan says thread" and "threading changes something" the same predicate.
|
|
3288
|
+
#
|
|
3289
|
+
# Cost is two walks of the body, the second only when the first found a write and no jump — the same
|
|
3290
|
+
# order of cost `StatementEvaluator`'s own per-call captured-write scan already pays, and far below
|
|
3291
|
+
# re-typing. The prefix walk is hand-rolled rather than `Source::NodeWalker.each` because the two
|
|
3292
|
+
# questions it answers have different depths: a write is collected at ANY depth (a block is a closure,
|
|
3293
|
+
# so `[1].each { v = 5 }` really does bind the outer `v`, and `[1].each { outer << 1 }` really does
|
|
3294
|
+
# mutate the outer `outer`), while a jump counts only above the nearest {JUMP_BOUNDARY_NODES} boundary.
|
|
3295
|
+
def tail_depends_on_body_binding?(statements)
|
|
3296
|
+
written = Set.new
|
|
3297
|
+
statements[0...-1].each do |statement|
|
|
3298
|
+
return false unless prefix_statement_jump_free?(statement, written, false)
|
|
3299
|
+
end
|
|
3300
|
+
return false if written.empty?
|
|
3301
|
+
|
|
3302
|
+
Source::NodeWalker.each(statements.last) do |node|
|
|
3303
|
+
return true if VARIABLE_READ_NODES.include?(node.class) && written.include?(node.name)
|
|
3304
|
+
end
|
|
3305
|
+
false
|
|
3306
|
+
end
|
|
3307
|
+
|
|
3308
|
+
# True when `node` cannot jump out of the block with a value, collecting into `written` the names it
|
|
3309
|
+
# binds (a variable-write node) or mutates in place (a {MutationWidening::SHAPE_MUTATORS} call, through
|
|
3310
|
+
# every variable its receiver can evaluate to) on the way down. `retargeted` is true once the descent
|
|
3311
|
+
# has passed a boundary.
|
|
3312
|
+
#
|
|
3313
|
+
# A `Prism::DefinedNode`'s operand is never evaluated, so it is not descended into — the same rule
|
|
3314
|
+
# {Source::NodeWalker} applies, for the same reason: neither a write nor a jump under `defined?` runs.
|
|
3315
|
+
def prefix_statement_jump_free?(node, written, retargeted)
|
|
3316
|
+
return false if !retargeted && JUMP_NODES.include?(node.class)
|
|
3317
|
+
|
|
3318
|
+
written << node.name if VARIABLE_WRITE_NODES.include?(node.class)
|
|
3319
|
+
collect_mutated_receivers(node, written) if node.is_a?(Prism::CallNode)
|
|
3320
|
+
return true if node.is_a?(Prism::DefinedNode)
|
|
3321
|
+
|
|
3322
|
+
child_retargeted = retargeted || JUMP_BOUNDARY_NODES.include?(node.class)
|
|
3323
|
+
node.rigor_each_child do |child|
|
|
3324
|
+
return false unless prefix_statement_jump_free?(child, written, child_retargeted)
|
|
3325
|
+
end
|
|
3326
|
+
true
|
|
3327
|
+
end
|
|
3328
|
+
|
|
3329
|
+
def collect_mutated_receivers(call_node, written)
|
|
3330
|
+
return unless MutationWidening::SHAPE_MUTATORS.include?(call_node.name)
|
|
3331
|
+
|
|
3332
|
+
ReceiverAlias.candidates(call_node.receiver).each { |read| written << read.name }
|
|
2541
3333
|
end
|
|
2542
3334
|
|
|
2543
3335
|
# v0.0.6 phase 2 — per-element block fold for Tuple receivers under `:map` / `:collect`. Walks every
|
|
@@ -2589,15 +3381,100 @@ module Rigor
|
|
|
2589
3381
|
# dispatched as a zero-arg method on each element type.
|
|
2590
3382
|
#
|
|
2591
3383
|
# Any other shape (`&proc_local`, `&method(:foo)`, no block) returns `nil` so the fold declines.
|
|
3384
|
+
# Arity cap on the scope-threading fold under this per-element walk. The `Constant<Range>` receivers are
|
|
3385
|
+
# already capped at {PER_ELEMENT_RANGE_LIMIT} positions, but a Tuple's elements come through
|
|
3386
|
+
# {#per_element_elements_of} uncapped — a 40-element array literal is 40 positions, and each one that
|
|
3387
|
+
# threads pays a FULL body evaluation rather than the tail-only typing this walk used to cost. Above the
|
|
3388
|
+
# cap the walk still runs (the fold's own per-position precision is unchanged); only the threading is
|
|
3389
|
+
# suppressed, so a body whose tail reads a body-local answers `Dynamic[top]` at every position instead
|
|
3390
|
+
# of its value. That is the cliff: `[1, …, 8].map do v = e; v end` folds to the values and a ninth
|
|
3391
|
+
# element drops the whole result to `Dynamic[top]` positions. It is set at the Range path's limit so one
|
|
3392
|
+
# number governs both receivers.
|
|
3393
|
+
PER_ELEMENT_THREADING_LIMIT = PER_ELEMENT_RANGE_LIMIT
|
|
3394
|
+
private_constant :PER_ELEMENT_THREADING_LIMIT
|
|
3395
|
+
|
|
2592
3396
|
def per_element_block_results(block, element_types)
|
|
2593
3397
|
case block
|
|
2594
3398
|
when Prism::BlockNode
|
|
2595
|
-
|
|
3399
|
+
per_element_body_results(block, element_types)
|
|
2596
3400
|
when Prism::BlockArgumentNode
|
|
2597
3401
|
per_element_symbol_results(block, element_types)
|
|
2598
3402
|
end
|
|
2599
3403
|
end
|
|
2600
3404
|
|
|
3405
|
+
def per_element_body_results(block, element_types)
|
|
3406
|
+
captured = per_element_captured_bindings(block, element_types)
|
|
3407
|
+
results = lambda do
|
|
3408
|
+
element_types.map { |element_type| type_block_body_with_param(block, [element_type], captured: captured) }
|
|
3409
|
+
end
|
|
3410
|
+
return results.call if element_types.size <= PER_ELEMENT_THREADING_LIMIT
|
|
3411
|
+
|
|
3412
|
+
without_block_body_threading(&results)
|
|
3413
|
+
end
|
|
3414
|
+
|
|
3415
|
+
# Issue #587 (b) — first-iteration pinning. Every position of this fold is typed from the SAME entry
|
|
3416
|
+
# scope, so a body that rebinds a captured outer local answers the FIRST iteration's value at every
|
|
3417
|
+
# position: `total = 0; [1, 2].map do total += 1; total end` folded to `[1, 1]` (runtime `[1, 2]`), and
|
|
3418
|
+
# `r.first == 1` then folded to `true` — a live always-truthy on correct code. The ADR-56 fixpoint
|
|
3419
|
+
# (`StatementEvaluator#write_back_block_captures`) already computes the honest binding of such a local
|
|
3420
|
+
# — the join over the pre-call binding and every permitted iteration, value-pin widened, floored to
|
|
3421
|
+
# `Dynamic[top]` on structural compounding (`x = [x]`) — but it runs AFTER the call is typed and feeds
|
|
3422
|
+
# only the continuation. This fold runs the same `BodyFixpoint` over the same name set
|
|
3423
|
+
# ({CapturedLocals.writes}) up front and binds each such local to its converged type in every
|
|
3424
|
+
# position's entry scope, so a position answers what the local can be in ANY iteration
|
|
3425
|
+
# (`[Integer, Integer]`), never what it was in the first.
|
|
3426
|
+
#
|
|
3427
|
+
# Only the rebound names move. A position whose tail reads an untouched captured local or a block-local
|
|
3428
|
+
# keeps its exact fold (`[5, 5]`, `[42, 42]`), and a predicate that ignores the rebound counter still
|
|
3429
|
+
# decides (`select do seen += 1; e > 1 end` still folds to `[2]`); a blanket decline would have lost all
|
|
3430
|
+
# three for nothing. The fixpoint binds the block parameter to the union of the elements, so its cost
|
|
3431
|
+
# is independent of the arity — which is why the per-element threading cap is NOT a reason to floor: a
|
|
3432
|
+
# ninth element keeps `Integer` where it would otherwise keep the stale `0`.
|
|
3433
|
+
#
|
|
3434
|
+
# Under threading suppression — this fold nested inside another threaded body — the fixpoint's body
|
|
3435
|
+
# evaluations are exactly the re-entrant cost the suppression exists to refuse, so the names take the
|
|
3436
|
+
# escaping-block floor (`Dynamic[top]`) instead: cheaper, wider, still sound. A failure inside the
|
|
3437
|
+
# fixpoint takes the same floor rather than the seed — a seed that reaches a position is the pin this
|
|
3438
|
+
# exists to remove.
|
|
3439
|
+
#
|
|
3440
|
+
# Returns `nil` (no binding to apply) for the overwhelmingly common body that rebinds nothing captured.
|
|
3441
|
+
def per_element_captured_bindings(block, element_types)
|
|
3442
|
+
names = CapturedLocals.writes(block, scope)
|
|
3443
|
+
return nil if names.empty?
|
|
3444
|
+
return captured_floor(names) if block_body_threading_suppressed?
|
|
3445
|
+
|
|
3446
|
+
begin
|
|
3447
|
+
converged_captured_bindings(block, names, element_types)
|
|
3448
|
+
rescue StandardError
|
|
3449
|
+
captured_floor(names)
|
|
3450
|
+
end
|
|
3451
|
+
end
|
|
3452
|
+
|
|
3453
|
+
def captured_floor(names)
|
|
3454
|
+
names.to_h { |name| [name, Type::Combinator.untyped] }
|
|
3455
|
+
end
|
|
3456
|
+
|
|
3457
|
+
def converged_captured_bindings(block, names, element_types)
|
|
3458
|
+
param_types = [Type::Combinator.union(*element_types)]
|
|
3459
|
+
BodyFixpoint.converge(
|
|
3460
|
+
names: names,
|
|
3461
|
+
seed_bindings: names.to_h { |name| [name, scope.local(name)] },
|
|
3462
|
+
widen: Type::Combinator.method(:widen_value_pinned),
|
|
3463
|
+
evaluate_body: ->(bindings) { captured_exit_bindings(block, param_types, bindings, names) }
|
|
3464
|
+
)
|
|
3465
|
+
end
|
|
3466
|
+
|
|
3467
|
+
# One fixpoint pass: the body evaluated from `bindings` with the block parameters bound over them (the
|
|
3468
|
+
# same layering as {#type_block_body_with_param}), returning the per-name exit binding. Threading is
|
|
3469
|
+
# suppressed for the pass, as it is for every full body evaluation the block-return pass runs.
|
|
3470
|
+
def captured_exit_bindings(block, param_types, bindings, names)
|
|
3471
|
+
params = BlockParameterBinder.new(expected_param_types: param_types).bind(block)
|
|
3472
|
+
entry = bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
|
|
3473
|
+
entry = params.reduce(entry) { |acc, (name, type)| acc.with_local(name, type) }
|
|
3474
|
+
_type, exit_scope = without_block_body_threading { entry.evaluate(block.body) }
|
|
3475
|
+
names.to_h { |name| [name, exit_scope.local(name)] }
|
|
3476
|
+
end
|
|
3477
|
+
|
|
2601
3478
|
def per_element_symbol_results(block_arg, element_types)
|
|
2602
3479
|
expression = block_arg.expression
|
|
2603
3480
|
return nil unless expression.is_a?(Prism::SymbolNode)
|
|
@@ -3006,14 +3883,16 @@ module Rigor
|
|
|
3006
3883
|
end
|
|
3007
3884
|
end
|
|
3008
3885
|
|
|
3009
|
-
|
|
3886
|
+
# `captured:` — issue #587 (b): the per-name entry binding of every captured outer local the body rebinds
|
|
3887
|
+
# ({#per_element_captured_bindings}), laid under the parameter bindings so a parameter still shadows.
|
|
3888
|
+
def type_block_body_with_param(block_node, expected_param_types, captured: nil)
|
|
3010
3889
|
bindings = BlockParameterBinder.new(expected_param_types: expected_param_types).bind(block_node)
|
|
3011
|
-
block_scope =
|
|
3890
|
+
block_scope = (captured || {}).reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
|
|
3891
|
+
block_scope = bindings.reduce(block_scope) { |acc, (name, type)| acc.with_local(name, type) }
|
|
3012
3892
|
type_block_body(block_node, block_scope)
|
|
3013
3893
|
rescue StandardError
|
|
3014
3894
|
nil
|
|
3015
3895
|
end
|
|
3016
3896
|
end
|
|
3017
|
-
# rubocop:enable Metrics/ClassLength
|
|
3018
3897
|
end
|
|
3019
3898
|
end
|