rigortype 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -222
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
  9. data/lib/rigor/analysis/check_rules.rb +275 -44
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
  12. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  13. data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
  14. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  15. data/lib/rigor/analysis/runner.rb +207 -1200
  16. data/lib/rigor/analysis/worker_session.rb +60 -11
  17. data/lib/rigor/bleeding_edge.rb +123 -0
  18. data/lib/rigor/cache/descriptor.rb +86 -8
  19. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  20. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  21. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  22. data/lib/rigor/cache/store.rb +46 -13
  23. data/lib/rigor/cli/annotate_command.rb +100 -15
  24. data/lib/rigor/cli/check_command.rb +708 -0
  25. data/lib/rigor/cli/ci_detector.rb +94 -0
  26. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  27. data/lib/rigor/cli/plugins_command.rb +2 -4
  28. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  29. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  30. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  31. data/lib/rigor/cli/trace_command.rb +143 -0
  32. data/lib/rigor/cli/trace_renderer.rb +310 -0
  33. data/lib/rigor/cli/triage_command.rb +6 -3
  34. data/lib/rigor/cli/triage_renderer.rb +15 -1
  35. data/lib/rigor/cli.rb +21 -612
  36. data/lib/rigor/configuration/severity_profile.rb +13 -1
  37. data/lib/rigor/configuration.rb +66 -7
  38. data/lib/rigor/environment/rbs_loader.rb +78 -68
  39. data/lib/rigor/environment.rb +1 -1
  40. data/lib/rigor/inference/acceptance.rb +10 -0
  41. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  42. data/lib/rigor/inference/budget_trace.rb +29 -2
  43. data/lib/rigor/inference/expression_typer.rb +1080 -105
  44. data/lib/rigor/inference/flow_tracer.rb +180 -0
  45. data/lib/rigor/inference/macro_block_self_type.rb +11 -12
  46. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  47. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  48. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  49. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  50. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  51. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  52. data/lib/rigor/inference/method_dispatcher.rb +187 -55
  53. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  54. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  55. data/lib/rigor/inference/mutation_widening.rb +142 -0
  56. data/lib/rigor/inference/narrowing.rb +330 -37
  57. data/lib/rigor/inference/scope_indexer.rb +770 -39
  58. data/lib/rigor/inference/statement_evaluator.rb +998 -68
  59. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  60. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  61. data/lib/rigor/plugin/base.rb +517 -120
  62. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  63. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  64. data/lib/rigor/plugin/macro.rb +2 -3
  65. data/lib/rigor/plugin/manifest.rb +4 -24
  66. data/lib/rigor/plugin/node_rule_walk.rb +192 -0
  67. data/lib/rigor/plugin/registry.rb +264 -35
  68. data/lib/rigor/plugin.rb +1 -0
  69. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  70. data/lib/rigor/scope/discovery_index.rb +60 -0
  71. data/lib/rigor/scope.rb +199 -204
  72. data/lib/rigor/sig_gen/generator.rb +8 -0
  73. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  74. data/lib/rigor/source/literals.rb +14 -0
  75. data/lib/rigor/triage/catalogue.rb +4 -19
  76. data/lib/rigor/triage.rb +69 -1
  77. data/lib/rigor/type/combinator.rb +34 -0
  78. data/lib/rigor/version.rb +1 -1
  79. data/lib/rigor.rb +0 -1
  80. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  81. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  82. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  83. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  84. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  85. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  86. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
  87. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  88. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
  89. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  90. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  91. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  93. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  94. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  96. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  97. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  98. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  99. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  100. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  102. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  103. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  104. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  105. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  106. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  107. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
  108. data/sig/rigor/analysis/fact_store.rbs +3 -0
  109. data/sig/rigor/environment.rbs +0 -2
  110. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  111. data/sig/rigor/inference.rbs +5 -0
  112. data/sig/rigor/plugin/base.rbs +6 -4
  113. data/sig/rigor/plugin/manifest.rbs +1 -2
  114. data/sig/rigor/scope.rbs +50 -29
  115. data/sig/rigor/source.rbs +1 -0
  116. data/sig/rigor/type.rbs +1 -0
  117. data/sig/rigor.rbs +1 -1
  118. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  119. data/skills/rigor-ci-setup/SKILL.md +319 -0
  120. data/skills/rigor-plugin-author/SKILL.md +6 -4
  121. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  122. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  123. metadata +21 -3
  124. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
  125. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -6,6 +6,7 @@ require_relative "../flow_contribution"
6
6
  require_relative "../flow_contribution/merger"
7
7
  require_relative "../builtins/hkt_builtins"
8
8
  require_relative "../builtins/static_return_refinements"
9
+ require_relative "flow_tracer"
9
10
  require_relative "method_dispatcher/call_context"
10
11
  require_relative "method_dispatcher/constant_folding"
11
12
  require_relative "method_dispatcher/literal_string_folding"
@@ -13,7 +14,9 @@ require_relative "method_dispatcher/shape_dispatch"
13
14
  require_relative "method_dispatcher/data_folding"
14
15
  require_relative "method_dispatcher/rbs_dispatch"
15
16
  require_relative "method_dispatcher/iterator_dispatch"
17
+ require_relative "method_dispatcher/reduce_folding"
16
18
  require_relative "method_dispatcher/block_folding"
19
+ require_relative "method_dispatcher/array_to_h_folding"
17
20
  require_relative "method_dispatcher/file_folding"
18
21
  require_relative "method_dispatcher/shellwords_folding"
19
22
  require_relative "method_dispatcher/math_folding"
@@ -73,9 +76,28 @@ module Rigor
73
76
  # @param environment [Rigor::Environment, nil] required for
74
77
  # RBS-backed dispatch; when nil only constant folding can fire.
75
78
  # @return [Rigor::Type, nil] inferred result type, or `nil` for "no rule".
76
- def dispatch(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
79
+ def dispatch(receiver_type:, method_name:, arg_types:,
77
80
  block_type: nil, environment: nil,
78
81
  call_node: nil, scope: nil)
82
+ result = resolve(
83
+ receiver_type: receiver_type, method_name: method_name, arg_types: arg_types,
84
+ block_type: block_type, environment: environment,
85
+ call_node: call_node, scope: scope
86
+ )
87
+ # `rigor trace` — record the dispatch outcome (resolved type, or
88
+ # the fail-soft `nil` the caller widens to `Dynamic[Top]`).
89
+ if FlowTracer.active?
90
+ FlowTracer.dispatch(
91
+ receiver: receiver_type, method_name: method_name, args: arg_types,
92
+ result: result, location: call_node&.location
93
+ )
94
+ end
95
+ result
96
+ end
97
+
98
+ def resolve(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
99
+ block_type: nil, environment: nil,
100
+ call_node: nil, scope: nil)
79
101
  return nil if receiver_type.nil?
80
102
 
81
103
  # Build the call context once and thread it — unchanged —
@@ -217,7 +239,7 @@ module Rigor
217
239
  # introspection (`attr_reader`, `private`, ...) on
218
240
  # user classes without requiring the user to author
219
241
  # their own RBS.
220
- try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type, call_node)
242
+ try_user_class_fallback(receiver_type, environment, call_node, context)
221
243
  end
222
244
 
223
245
  # v0.1.3 — discovered-method dispatch tier. `scope` carries
@@ -254,7 +276,14 @@ module Rigor
254
276
  class_name, kind = discovered_method_lookup(receiver_type)
255
277
  return nil if class_name.nil?
256
278
  return nil unless scope.discovered_method?(class_name, method_name, kind)
279
+ # Decline when a re-typable body is recorded for the method, so the
280
+ # downstream `ExpressionTyper` inference tier can fold a precise
281
+ # return instead of collapsing to `Dynamic[top]` here — instance
282
+ # bodies via `user_def_for`, singleton bodies (`def self.x` /
283
+ # `module_function`) via `singleton_def_for` (module-singleton
284
+ # call resolution, ADR-57 follow-up).
257
285
  return nil if kind == :instance && scope.user_def_for(class_name, method_name)
286
+ return nil if kind == :singleton && scope.singleton_def_for(class_name, method_name)
258
287
 
259
288
  Type::Combinator.untyped
260
289
  end
@@ -298,12 +327,12 @@ module Rigor
298
327
  end
299
328
 
300
329
  # ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
301
- # slice 7. Walks every loaded plugin's
302
- # `#flow_contribution_for(call_node:, scope:)` hook,
303
- # collects the non-nil `FlowContribution` bundles, merges
304
- # them through `FlowContribution::Merger`, and returns
305
- # the merged `return_type` slot (or nil when no plugin
306
- # contributed a return type).
330
+ # slice 7; ADR-52 WD3 — consults each loaded plugin's gated
331
+ # `dynamic_return` rules, wraps the contributed types as
332
+ # `FlowContribution` bundles, merges them through
333
+ # `FlowContribution::Merger`, and returns the merged
334
+ # `return_type` slot (or nil when no plugin contributed a
335
+ # return type).
307
336
  #
308
337
  # Plugins whose hook raises have their contribution
309
338
  # silently dropped for this call so the dispatch chain
@@ -643,23 +672,15 @@ module Rigor
643
672
  Type::Combinator.untyped
644
673
  end
645
674
 
675
+ # ADR-52 WD1 — the per-dispatch plugins × owns_receivers ×
676
+ # `class_ordering` walk moved into the compiled contribution
677
+ # table: the union is built once per registry (almost always
678
+ # empty → O(1) false) and per-class verdicts memoise per run.
646
679
  def plugin_owns_receiver?(class_name, environment)
647
680
  registry = environment&.plugin_registry
648
681
  return false if registry.nil? || registry.empty?
649
682
 
650
- registry.plugins.any? do |plugin|
651
- owns = plugin.manifest.owns_receivers # rigor:disable undefined-method
652
- owns.any? { |owner| receiver_matches_owner?(class_name, owner, environment) }
653
- end
654
- end
655
-
656
- def receiver_matches_owner?(class_name, owner, environment)
657
- return true if class_name == owner
658
-
659
- ordering = environment.class_ordering(class_name, owner)
660
- %i[equal subclass].include?(ordering)
661
- rescue StandardError
662
- false
683
+ registry.contribution_index.owns_receiver?(class_name, environment)
663
684
  end
664
685
 
665
686
  def dep_source_class_name(receiver_type)
@@ -668,11 +689,11 @@ module Rigor
668
689
  end
669
690
  end
670
691
 
671
- # ADR-37 slice 2 — gathers each plugin's return-type contribution
672
- # from BOTH the narrow `dynamic_return` DSL (receiver-gated, wrapped
673
- # as a return-only `FlowContribution`) and the legacy
674
- # `flow_contribution_for` escape valve, so migrated and unmigrated
675
- # plugins compose through the same merger.
692
+ # ADR-37 slice 2 / ADR-52 WD3 — gathers each plugin's return-type
693
+ # contribution from the gated `dynamic_return` DSL, wrapped as a
694
+ # return-only `FlowContribution` for the shared merger. (The legacy
695
+ # ungated `flow_contribution_for` escape valve was deleted once its
696
+ # five users migrated.)
676
697
  EMPTY_CONTRIBUTIONS = [].freeze
677
698
  private_constant :EMPTY_CONTRIBUTIONS
678
699
 
@@ -682,31 +703,48 @@ module Rigor
682
703
  #
683
704
  # 1. Only the plugins that *structurally* implement a per-call path
684
705
  # are visited — `registry.contribution_index.for_method_dispatch`
685
- # is the registry-ordered subset overriding `flow_contribution_for`
686
- # or declaring a `dynamic_return` (GitLab: 2 of 11). Iterating the
687
- # subset in registry order, and gating each path by membership,
688
- # yields the exact same contributions in the same order as
689
- # visiting every plugin would (a skipped plugin's call returns
690
- # nil/[] anyway). The receiver-class ancestry match still happens
691
- # per dispatch inside `dynamic_return_type`.
706
+ # is the registry-ordered subset declaring a `dynamic_return`.
707
+ # Iterating the subset in registry order, and gating each path by
708
+ # membership, yields the exact same contributions in the same
709
+ # order as visiting every plugin would (a skipped plugin's call
710
+ # returns nil/[] anyway). The receiver-class ancestry match still
711
+ # happens per dispatch inside `dynamic_return_type`.
692
712
  # 2. Contributions accumulate lazily — allocate only when one
693
713
  # actually appears, and share a frozen empty array otherwise. The
694
714
  # caller treats the result as read-only (`.empty?` / `Merger.merge`).
715
+ # 3. ADR-52 WD1 — method-name gates compiled at registry build. The
716
+ # global gate makes the common "no plugin cares about this call"
717
+ # case a single Set probe; the per-plugin gate skips a plugin
718
+ # whose `dynamic_return` rules are all `methods:`-gated on other
719
+ # names. A pruned consultation could only have returned nil, so
720
+ # contribution order and content are unchanged.
695
721
  def collect_plugin_contributions(registry, call_node, scope, receiver_type)
696
722
  index = registry.contribution_index
697
723
  relevant = index.for_method_dispatch
698
724
  return EMPTY_CONTRIBUTIONS if relevant.empty?
699
725
 
726
+ # `call_node` is not always a CallNode — the `&:symbol` block
727
+ # path dispatches with the `Prism::BlockArgumentNode` itself
728
+ # (`ExpressionTyper#symbol_block_return_type`). A bare `.name`
729
+ # here raised, and the raise was silently absorbed by
730
+ # `block_return_type_for`'s rescue, nil-ing the block type and
731
+ # flipping `select(&:p)`-style calls onto their no-block
732
+ # Enumerator overloads (caught by the GitLab corpus gate).
733
+ name = call_node.respond_to?(:name) ? call_node.name : nil
734
+ return EMPTY_CONTRIBUTIONS unless index.dispatch_candidate?(name)
735
+
736
+ collect_gated_contributions(index, relevant, name, call_node, scope, receiver_type)
737
+ end
738
+
739
+ # The post-gate walk, in registry order — the same order the
740
+ # ungated walk used.
741
+ def collect_gated_contributions(index, relevant, name, call_node, scope, receiver_type)
700
742
  result = nil
701
743
  relevant.each do |plugin|
702
- if index.flow?(plugin)
703
- legacy = plugin.flow_contribution_for(call_node: call_node, scope: scope)
704
- (result ||= []) << legacy if legacy.is_a?(FlowContribution)
705
- end
706
- if index.dynamic?(plugin)
707
- dynamic = plugin.dynamic_return_type(call_node: call_node, scope: scope, receiver_type: receiver_type)
708
- (result ||= []) << FlowContribution.new(return_type: dynamic) if dynamic
709
- end
744
+ next unless index.dynamic_candidate_for?(plugin, name)
745
+
746
+ dynamic = plugin.dynamic_return_type(call_node: call_node, scope: scope, receiver_type: receiver_type)
747
+ (result ||= []) << FlowContribution.new(return_type: dynamic) if dynamic
710
748
  rescue StandardError
711
749
  next
712
750
  end
@@ -735,13 +773,37 @@ module Rigor
735
773
  # filter the common cases first. Adding a precise tier is a
736
774
  # one-line append here rather than another link in a hand-written
737
775
  # `||` ladder.
738
- PRECISE_TIERS = Ractor.make_shareable([
739
- ConstantFolding, LiteralStringFolding, ShapeDispatch,
740
- FileFolding, ShellwordsFolding, MathFolding, TimeFolding,
741
- RegexpFolding, CGIFolding, URIFolding, SetFolding,
742
- KernelDispatch, MethodFolding, BlockFolding
776
+ PRECISE_TIERS_HEAD = Ractor.make_shareable([
777
+ ConstantFolding, LiteralStringFolding, ShapeDispatch
743
778
  ].freeze)
744
- private_constant :PRECISE_TIERS
779
+ private_constant :PRECISE_TIERS_HEAD
780
+
781
+ # ADR-53 re-review follow-up (gate-by-held-key applied to the
782
+ # built-in tiers): the eight stdlib singleton folders are mutually
783
+ # exclusive — each fires only on `Singleton[<its class>]`, the
784
+ # first check in every `try_dispatch` — so at most one can match a
785
+ # given receiver and their relative trial order was never
786
+ # observable. Compiling them into a class-name table turns eight
787
+ # no-op trials per call into one Hash read, skipped entirely when
788
+ # the receiver is not a `Singleton` (the overwhelmingly common
789
+ # case). The table sits where the eight sat in the old flat list:
790
+ # after ShapeDispatch, before KernelDispatch.
791
+ STDLIB_SINGLETON_FOLDERS = Ractor.make_shareable({
792
+ "File" => FileFolding,
793
+ "Shellwords" => ShellwordsFolding,
794
+ "Math" => MathFolding,
795
+ "Time" => TimeFolding,
796
+ "Regexp" => RegexpFolding,
797
+ "CGI" => CGIFolding,
798
+ "URI" => URIFolding,
799
+ "Set" => SetFolding
800
+ }.freeze)
801
+ private_constant :STDLIB_SINGLETON_FOLDERS
802
+
803
+ PRECISE_TIERS_TAIL = Ractor.make_shareable([
804
+ KernelDispatch, MethodFolding, ReduceFolding, ArrayToHFolding, BlockFolding
805
+ ].freeze)
806
+ private_constant :PRECISE_TIERS_TAIL
745
807
 
746
808
  def dispatch_precise_tiers(context)
747
809
  # ADR-48 — Data value folding runs ahead of meta-introspection:
@@ -756,14 +818,25 @@ module Rigor
756
818
  meta_result = try_meta_introspection(context.receiver, context.method_name, context.args)
757
819
  return meta_result if meta_result
758
820
 
759
- PRECISE_TIERS.each do |tier|
821
+ PRECISE_TIERS_HEAD.each do |tier|
822
+ result = tier.try_dispatch(context)
823
+ return result if result
824
+ end
825
+
826
+ receiver = context.receiver
827
+ if receiver.is_a?(Type::Singleton) && (folder = STDLIB_SINGLETON_FOLDERS[receiver.class_name])
828
+ result = folder.try_dispatch(context)
829
+ return result if result
830
+ end
831
+
832
+ PRECISE_TIERS_TAIL.each do |tier|
760
833
  result = tier.try_dispatch(context)
761
834
  return result if result
762
835
  end
763
836
  nil
764
837
  end
765
838
 
766
- def try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type, call_node = nil)
839
+ def try_user_class_fallback(receiver_type, environment, call_node, context)
767
840
  return nil if environment.nil?
768
841
 
769
842
  fallback_receiver = user_class_fallback_receiver(receiver_type, environment)
@@ -788,14 +861,11 @@ module Rigor
788
861
  # self / `self.`-receiver calls (`puts`, `raise`, `require`)
789
862
  # keep resolving — those are the fallback's intended targets.
790
863
  RbsDispatch.try_dispatch(
791
- CallContext.build(
864
+ context.with(
792
865
  receiver: fallback_receiver,
793
- method_name: method_name,
794
- args: arg_types,
795
- environment: environment,
796
- block_type: block_type,
797
866
  self_type_override: receiver_type,
798
- public_only: explicit_non_self_receiver?(call_node)
867
+ public_only: explicit_non_self_receiver?(call_node),
868
+ call_node: nil, scope: nil
799
869
  )
800
870
  )
801
871
  end
@@ -891,18 +961,53 @@ module Rigor
891
961
  set_lift = set_new_lift(receiver_type.class_name, arg_types)
892
962
  return set_lift if set_lift
893
963
 
964
+ hash_lift = hash_new_lift(receiver_type.class_name, arg_types)
965
+ return hash_lift if hash_lift
966
+
894
967
  regexp_lift = regexp_new_lift(receiver_type.class_name, arg_types)
895
968
  return regexp_lift if regexp_lift
896
969
 
897
970
  date_lift = date_new_lift(receiver_type.class_name, arg_types)
898
971
  return date_lift if date_lift
899
972
 
973
+ struct_new_lift = struct_new_lift(receiver_type.class_name, arg_types)
974
+ return struct_new_lift if struct_new_lift
975
+
900
976
  class_new_lift = class_new_lift(receiver_type.class_name, arg_types)
901
977
  return class_new_lift if class_new_lift
902
978
 
903
979
  Type::Combinator.nominal_of(receiver_type.class_name)
904
980
  end
905
981
 
982
+ # `Struct.new(:a, :b)` synthesises an anonymous Struct *subclass*
983
+ # (a class object), not a Struct *instance* — so the chained
984
+ # idiom `Struct.new(:a, :b).new(1, 2)` must resolve `.new` again
985
+ # on a class-like carrier. The constant-bound form
986
+ # (`S = Struct.new(:a); S.new(1)`) already records `Singleton[S]`
987
+ # via `ScopeIndexer#record_meta_new_constant?`; this lift gives
988
+ # the *chained* (anonymous) position the same class-like carrier
989
+ # so the trailing `.new` dispatches instead of firing a spurious
990
+ # `undefined method 'new' for Struct`.
991
+ #
992
+ # The disambiguation mirrors `ScopeIndexer#struct_new_call?`: a
993
+ # call whose positionals are all `Constant<Symbol>` literals is a
994
+ # member-list class definition → `Singleton[Struct]`. The
995
+ # following `AnonStruct.new(1, 2)` carries non-symbol args, so it
996
+ # falls through this gate to `Nominal[Struct]` (a fresh instance)
997
+ # via the `meta_new` tail. ADR-48 deferred full Struct *value*
998
+ # folding (member-reader precision) on mutability grounds; this is
999
+ # the narrower `.new`-dispatch-only fix and contributes no member
1000
+ # layout, so `instance.a` stays at its RBS/Dynamic type.
1001
+ def struct_new_lift(class_name, arg_types)
1002
+ return nil unless class_name == "Struct"
1003
+
1004
+ positional = arg_types.grep_v(Type::HashShape)
1005
+ return nil if positional.empty?
1006
+ return nil unless positional.all? { |t| t.is_a?(Type::Constant) && t.value.is_a?(Symbol) }
1007
+
1008
+ Type::Combinator.singleton_of("Struct")
1009
+ end
1010
+
906
1011
  # `Class.new` and `Class.new(Parent)` create a brand-new
907
1012
  # anonymous class. Statically that class is representable as
908
1013
  # the parent's singleton type — its singleton-method surface
@@ -989,6 +1094,33 @@ module Rigor
989
1094
  type
990
1095
  end
991
1096
 
1097
+ # `Hash.new(default)` — lifts the default value's type into the
1098
+ # Hash's value parameter so a subsequent `h[k]` read surfaces the
1099
+ # default type rather than `Dynamic[top]`. The common counter
1100
+ # idiom `h = Hash.new(0); h[k] += 1` then types the read as
1101
+ # `Integer`. The key parameter is left `untyped` (the default
1102
+ # carrier imposes no key constraint), so reads of any key resolve
1103
+ # through the value parameter. A value-pinned `Constant` default
1104
+ # (`0`) is widened to its nominal (`Integer`): the hash's values
1105
+ # mutate over its lifetime, so pinning the parameter to the
1106
+ # literal would be unsound for the aggregate.
1107
+ #
1108
+ # Only the single-argument default form folds. The zero-arg
1109
+ # (`Hash.new`) and the block form (`Hash.new { |h, k| … }`) keep
1110
+ # the bare `Nominal[Hash]` answer — the block's value type is not
1111
+ # available at this `:new` dispatch site, and conservatively
1112
+ # leaving the read as today's behaviour is precision-additive.
1113
+ def hash_new_lift(class_name, arg_types)
1114
+ return nil unless class_name == "Hash"
1115
+ return nil unless arg_types.size == 1
1116
+
1117
+ default = arg_types.first
1118
+ return nil if default.nil?
1119
+
1120
+ value = Type::Combinator.widen_value_pinned(default)
1121
+ Type::Combinator.nominal_of("Hash", type_args: [Type::Combinator.untyped, value])
1122
+ end
1123
+
992
1124
  # `Range.new(b, e)` / `Range.new(b, e, excl)` — folds to
993
1125
  # `Constant[Range]` when both endpoints are `Constant[Integer]`
994
1126
  # or both are `Constant[String]`, and the optional third argument
@@ -32,6 +32,35 @@ module Rigor
32
32
  # `#singleton_method`.
33
33
  #
34
34
  # See docs/internal-spec/inference-engine.md for the binding contract.
35
+ # Leaf-name extraction for a destructured positional parameter
36
+ # (`Prism::MultiTargetNode`). Stateless; lifted out of
37
+ # {MethodParameterBinder} so the binder's class length stays in
38
+ # budget.
39
+ module Destructure
40
+ module_function
41
+
42
+ # Collect every leaf local name a `MultiTargetNode` binds,
43
+ # recursing through nested destructures (`((a, b), c)`) and the
44
+ # splat slot (`(a, *rest)`). Targets without a `#name` (an
45
+ # index/call write target, vanishingly rare in a parameter
46
+ # position) are skipped — there is no local to bind.
47
+ def target_names(multi_target)
48
+ entries = multi_target.lefts + [multi_target.rest, *multi_target.rights].compact
49
+ entries.flat_map { |entry| names_for_entry(entry) }
50
+ end
51
+
52
+ def names_for_entry(entry)
53
+ # A splat sub-target (`*rest` inside the destructure) wraps its
54
+ # real target in a `SplatNode#expression`; unwrap it.
55
+ entry = entry.expression if entry.is_a?(Prism::SplatNode) && entry.expression
56
+ return [] if entry.nil?
57
+ return target_names(entry) if entry.is_a?(Prism::MultiTargetNode)
58
+ return [entry.name] if entry.respond_to?(:name) && entry.name
59
+
60
+ []
61
+ end
62
+ end
63
+
35
64
  class MethodParameterBinder
36
65
  # @param environment [Rigor::Environment]
37
66
  # @param class_path [String, nil] the qualified name of the class
@@ -112,14 +141,39 @@ module Rigor
112
141
 
113
142
  def positional_slots(params_node)
114
143
  slots = []
115
- params_node.requireds.each_with_index { |p, i| slots << ParamSlot.new(:required_positional, p.name, i) }
144
+ params_node.requireds.each_with_index do |p, i|
145
+ append_positional_slot(slots, :required_positional, p, i)
146
+ end
116
147
  params_node.optionals.each_with_index { |p, i| slots << ParamSlot.new(:optional_positional, p.name, i) }
117
148
  rest = params_node.rest
118
149
  slots << ParamSlot.new(:rest_positional, rest.name, nil) if rest.respond_to?(:name) && rest&.name
119
- params_node.posts.each_with_index { |p, i| slots << ParamSlot.new(:trailing_positional, p.name, i) }
150
+ params_node.posts.each_with_index do |p, i|
151
+ append_positional_slot(slots, :trailing_positional, p, i)
152
+ end
120
153
  slots
121
154
  end
122
155
 
156
+ # A destructured positional parameter — `def f((a, b))` — is a
157
+ # `Prism::MultiTargetNode` in the `requireds`/`posts` list, not a
158
+ # `RequiredParameterNode`, so it has no `#name`. Bind each leaf
159
+ # sub-target local to `Dynamic[Top]` (a `:destructured_positional`
160
+ # slot with no RBS index) instead of crashing on a blind `.name`.
161
+ # Binding the names at all is what matters: it keeps the
162
+ # destructured locals present in the entry scope so the body's
163
+ # reads of them don't fall through to undefined-local noise. The
164
+ # element types are not cheaply available from the parameter list
165
+ # alone (no RBS function param maps onto a destructured slot), so
166
+ # `Dynamic[Top]` is the sound default.
167
+ def append_positional_slot(slots, kind, param, index)
168
+ if param.is_a?(Prism::MultiTargetNode)
169
+ Destructure.target_names(param).each do |name|
170
+ slots << ParamSlot.new(:destructured_positional, name, nil)
171
+ end
172
+ else
173
+ slots << ParamSlot.new(kind, param.name, index)
174
+ end
175
+ end
176
+
123
177
  def keyword_slots(params_node)
124
178
  params_node.keywords.filter_map do |kw|
125
179
  case kw
@@ -100,19 +100,62 @@ module Rigor
100
100
 
101
101
  def decompose_tuple(tuple, front_count, back_count, rest_present:)
102
102
  elements = tuple.elements
103
- fronts = Array.new(front_count) { |i| elements[i] || Type::Combinator.constant_of(nil) }
103
+ fronts = Array.new(front_count) { |i| slot_type(elements, i) }
104
104
  if rest_present
105
105
  middle_end = [elements.size - back_count, front_count].max
106
106
  middle = elements[front_count...middle_end] || []
107
107
  rest_type = Type::Combinator.tuple_of(*middle)
108
- backs = Array.new(back_count) { |i| elements[middle_end + i] || Type::Combinator.constant_of(nil) }
108
+ backs = Array.new(back_count) { |i| slot_type(elements, middle_end + i) }
109
109
  else
110
110
  rest_type = nil
111
- backs = Array.new(back_count) { |i| elements[front_count + i] || Type::Combinator.constant_of(nil) }
111
+ backs = Array.new(back_count) { |i| slot_type(elements, front_count + i) }
112
112
  end
113
113
  [fronts, rest_type, backs]
114
114
  end
115
115
 
116
+ # The per-slot type for index `i` of a tuple decomposition, FP-safely
117
+ # softened: a missing slot is `nil` (the runtime value of an
118
+ # over-destructured positional), and a PRESENT but nil-bearing slot
119
+ # (`X | nil`) is softened to its non-`nil` part — for a heterogeneous
120
+ # `Tuple` whose optional slot was made optional by flow.
121
+ #
122
+ # Rationale (ADR-57 slice 3 work-item 2): a destructure of a tuple
123
+ # element that flow typed as optional is almost always guarded by a
124
+ # CORRELATED invariant the flow engine cannot prove. The canonical case
125
+ # is haml's `parse_tag`, which returns `[..., last_line || @line.index
126
+ # + 1]` — a 9-tuple whose `last_line` slot widens to `Dynamic[top]?`
127
+ # through a loop-nested destructure; at the call site `..., last_line =
128
+ # parse_tag(text); raise(..., last_line - 1) if parse && value.empty?`
129
+ # the `last_line` is nil ONLY when an earlier element is too, and the
130
+ # guard short-circuits — but that correlation lives across slots, so
131
+ # per-slot flow sees `last_line` as nil-able and `last_line - 1` fires a
132
+ # spurious `possible nil receiver`. Manufacturing a `T?` for every
133
+ # destructured slot frightens working code; FP discipline (the program
134
+ # works) outranks the worst-case per-slot reading, so we drop the `nil`
135
+ # from a destructured slot and keep the non-`nil` constituent (a bare
136
+ # `nil` slot stays `nil` — there is nothing to soften). A pure non-
137
+ # optional element keeps its precise type unchanged.
138
+ def slot_type(elements, index)
139
+ element = elements[index]
140
+ return Type::Combinator.constant_of(nil) if element.nil?
141
+
142
+ soften_optional_slot(element)
143
+ end
144
+
145
+ def soften_optional_slot(element)
146
+ return element unless element.is_a?(Type::Union)
147
+ return element unless element.members.any? { |m| nil_literal?(m) }
148
+
149
+ non_nil = element.members.reject { |m| nil_literal?(m) }
150
+ return element if non_nil.empty? # a bare `nil` slot: nothing to soften
151
+
152
+ Type::Combinator.union(*non_nil)
153
+ end
154
+
155
+ def nil_literal?(member)
156
+ member.is_a?(Type::Constant) && member.value.nil?
157
+ end
158
+
116
159
  def decompose_default(front_count, back_count, rest_present:)
117
160
  [
118
161
  Array.new(front_count) { Type::Combinator.untyped },
@@ -280,6 +280,148 @@ module Rigor
280
280
  carriers = kinds.map { |name| Type::Combinator.nominal_of(name) }
281
281
  carriers.size == 1 ? carriers.first : Type::Combinator.union(*carriers)
282
282
  end
283
+
284
+ # ----------------------------------------------------------------
285
+ # ADR-56 slice C — receiver-content element-type JOIN.
286
+ #
287
+ # `widen_after_block` above forgets a literal-shape carrier's arity
288
+ # when a captured local is content-mutated inside a block, but it
289
+ # keeps only the SEED's element types — an unsound under-
290
+ # approximation for a non-empty seed (`out = [0]; arr.each { |x|
291
+ # out << x }` types `Array[0]` while the runtime array is
292
+ # `[0, 1, 2, 3]`). Slice C joins the appended/stored element (and
293
+ # key/value) types INTO the continuation collection's parameter, so
294
+ # the result is `Array[0 | Integer]` rather than `Array[0]`.
295
+ #
296
+ # Array content-mutators that append/store ELEMENTS. The appended
297
+ # element type is the call's argument type(s); `[]=`'s value is its
298
+ # LAST argument (the keys precede it). Subset of `ARRAY_MUTATORS`:
299
+ # only the element-INTRODUCING methods (removers / reorderers add no
300
+ # new element evidence and are already covered by the arity-forget).
301
+ ARRAY_CONTENT_ADDERS = %i[
302
+ << push append prepend unshift concat insert []= fill replace
303
+ ].to_set.freeze
304
+
305
+ # Hash content-mutators that store a key→value pair. For `[]=` /
306
+ # `store` the key is the first argument and the value the last.
307
+ HASH_CONTENT_ADDERS = %i[[]= store].to_set.freeze
308
+
309
+ # String content-mutators that append to the buffer. String carries
310
+ # no element parameter, so these contribute nothing to a join — they
311
+ # are listed so the orchestrator recognises them as content mutators
312
+ # (the binding already widens to `String` via normal typing); the
313
+ # join helpers below short-circuit on a non-collection pre-state.
314
+ STRING_CONTENT_ADDERS = %i[<< concat prepend insert replace].to_set.freeze
315
+
316
+ # Every method name that mutates a captured local's CONTENT — the
317
+ # union the orchestrator scans the block body for.
318
+ CONTENT_ADDERS = (ARRAY_CONTENT_ADDERS | HASH_CONTENT_ADDERS | STRING_CONTENT_ADDERS).freeze
319
+
320
+ # The element types a single content-mutator call introduces into an
321
+ # Array, given the per-argument types (already typed in the block
322
+ # body scope). `concat`/`replace` take collection arguments, so their
323
+ # element evidence is the arguments' OWN element types unioned; the
324
+ # rest append the argument values directly. Returns `[]` when no
325
+ # element evidence (e.g. a `<<` with no resolvable arg).
326
+ def array_added_elements(method_name, arg_types)
327
+ return [] if arg_types.empty?
328
+
329
+ case method_name
330
+ when :concat, :replace
331
+ arg_types.flat_map { |t| collection_element_types(t) }
332
+ when :insert
333
+ # `insert(index, *objs)` — first arg is the position.
334
+ arg_types.drop(1)
335
+ when :[]=
336
+ # `arr[i] = v` / `arr[i, n] = v` — value is the last argument.
337
+ [arg_types.last]
338
+ when :fill
339
+ # `fill(value)` — only the no-block single-value form adds a
340
+ # concrete element; block / range forms are conservatively
341
+ # ignored (the arity-forget already widened the binding).
342
+ arg_types.size == 1 ? arg_types : []
343
+ else # << push append prepend unshift
344
+ arg_types
345
+ end
346
+ end
347
+
348
+ # Builds the continuation Array type from the pre-state binding and
349
+ # the appended element types. The floor is `Array[Dynamic[top]]`
350
+ # (the sound empty-seed behaviour) when there is no element evidence
351
+ # at all.
352
+ def join_array_content(pre_state, added_elements)
353
+ seed_elements = collection_element_types(pre_state)
354
+ added = added_elements.compact
355
+ # The empty-seed floor element is `Dynamic[top]` (no element
356
+ # evidence). When real appended evidence exists that floor carries
357
+ # nothing, so drop it — an empty accumulator built by `out << x*2`
358
+ # reads `Array[Integer]`, not `Array[Integer | Dynamic[top]]`.
359
+ seed_elements = drop_dynamic(seed_elements) unless added.empty?
360
+ elements = seed_elements + added
361
+ return Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped]) if elements.empty?
362
+
363
+ Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.union(*elements)])
364
+ end
365
+
366
+ # Builds the continuation Hash type from the pre-state binding and a
367
+ # list of `[key_type, value_type]` pairs stored by `[]=` / `store`.
368
+ def join_hash_content(pre_state, added_pairs)
369
+ seed_keys, seed_values = hash_shape_key_values(pre_state)
370
+ added_keys = added_pairs.map(&:first).compact
371
+ added_values = added_pairs.map(&:last).compact
372
+ seed_keys = drop_dynamic(seed_keys) unless added_keys.empty?
373
+ seed_values = drop_dynamic(seed_values) unless added_values.empty?
374
+ keys = seed_keys + added_keys
375
+ values = seed_values + added_values
376
+ key_t = keys.empty? ? Type::Combinator.untyped : Type::Combinator.union(*keys)
377
+ value_t = values.empty? ? Type::Combinator.untyped : Type::Combinator.union(*values)
378
+ Type::Combinator.nominal_of("Hash", type_args: [key_t, value_t])
379
+ end
380
+
381
+ # Drops `Dynamic` (incl. `untyped`) constituents from a type list.
382
+ def drop_dynamic(types)
383
+ types.grep_v(Type::Dynamic)
384
+ end
385
+
386
+ # Element types carried by a collection binding, regardless of which
387
+ # carrier holds them: a `Tuple` lists them, a `Nominal[Array, [E]]`
388
+ # has one element param, a bare `Array` / anything else yields none.
389
+ def collection_element_types(type)
390
+ case type
391
+ when Type::Tuple
392
+ type.elements
393
+ when Type::Nominal
394
+ type.class_name == "Array" ? type.type_args : []
395
+ when Type::Union
396
+ # A loop's single-pass join can union the widened collection with
397
+ # its un-widened literal seed (`Array[0] | [0]`); pull element
398
+ # evidence from every Array-ish member.
399
+ type.members.flat_map { |m| collection_element_types(m) }
400
+ else
401
+ []
402
+ end
403
+ end
404
+
405
+ # `[keys, values]` evidence from a Hash-ish pre-state binding —
406
+ # a `HashShape` (literal pairs) or a `Nominal[Hash, [K, V]]`.
407
+ def hash_shape_key_values(type)
408
+ case type
409
+ when Type::HashShape
410
+ return [[], []] if type.pairs.empty?
411
+
412
+ [[key_union_for(type.pairs.keys)], type.pairs.values]
413
+ when Type::Nominal
414
+ type.class_name == "Hash" && type.type_args.size == 2 ? [[type.type_args[0]], [type.type_args[1]]] : [[], []]
415
+ when Type::Union
416
+ type.members.each_with_object([[], []]) do |m, (ks, vs)|
417
+ mk, mv = hash_shape_key_values(m)
418
+ ks.concat(mk)
419
+ vs.concat(mv)
420
+ end
421
+ else
422
+ [[], []]
423
+ end
424
+ end
283
425
  end
284
426
  end
285
427
  end