rigortype 0.3.6 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +409 -11
  14. data/docs/manual/02-cli-reference.md +16 -7
  15. data/docs/manual/05-inspecting-types.md +20 -8
  16. data/docs/manual/07-plugins.md +4 -0
  17. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  18. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  19. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  20. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  21. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  22. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  23. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  24. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  25. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  26. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  27. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  28. data/lib/rigor/analysis/check_rules.rb +383 -48
  29. data/lib/rigor/analysis/crash_signature.rb +116 -0
  30. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  31. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  32. data/lib/rigor/analysis/incremental.rb +28 -0
  33. data/lib/rigor/analysis/incremental_session.rb +44 -9
  34. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  35. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  36. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  37. data/lib/rigor/analysis/result.rb +32 -0
  38. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  39. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
  40. data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
  41. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  42. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
  43. data/lib/rigor/analysis/runner.rb +147 -10
  44. data/lib/rigor/analysis/worker_session.rb +10 -1
  45. data/lib/rigor/bleeding_edge.rb +7 -6
  46. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  47. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  48. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  49. data/lib/rigor/cache/descriptor.rb +42 -2
  50. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  51. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
  52. data/lib/rigor/cache/store.rb +10 -1
  53. data/lib/rigor/cli/coverage_command.rb +23 -18
  54. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  55. data/lib/rigor/cli/coverage_scan.rb +47 -6
  56. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  57. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  58. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  59. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  60. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  61. data/lib/rigor/cli/sig_gen_command.rb +18 -0
  62. data/lib/rigor/cli/type_of_command.rb +188 -41
  63. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  64. data/lib/rigor/cli/type_scan_command.rb +10 -1
  65. data/lib/rigor/cli.rb +1 -1
  66. data/lib/rigor/configuration.rb +7 -4
  67. data/lib/rigor/effects/plugin_facts.rb +1 -1
  68. data/lib/rigor/effects/scanner.rb +2 -3
  69. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  70. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  71. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  72. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  73. data/lib/rigor/environment/rbs_loader.rb +525 -46
  74. data/lib/rigor/environment.rb +38 -15
  75. data/lib/rigor/inference/acceptance.rb +172 -20
  76. data/lib/rigor/inference/captured_locals.rb +62 -0
  77. data/lib/rigor/inference/content_join.rb +347 -0
  78. data/lib/rigor/inference/def_handle.rb +15 -3
  79. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  80. data/lib/rigor/inference/expression_typer.rb +1020 -141
  81. data/lib/rigor/inference/fork_map.rb +6 -1
  82. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  83. data/lib/rigor/inference/hkt_registry.rb +46 -0
  84. data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
  85. data/lib/rigor/inference/index_write_widening.rb +48 -0
  86. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  87. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  88. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  89. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  92. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  93. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  95. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  96. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  97. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  98. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  99. data/lib/rigor/inference/mutation_widening.rb +244 -174
  100. data/lib/rigor/inference/narrowing.rb +331 -27
  101. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  102. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  103. data/lib/rigor/inference/precision_scanner.rb +5 -2
  104. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  105. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  106. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  107. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  108. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  109. data/lib/rigor/inference/version_guard.rb +229 -0
  110. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  111. data/lib/rigor/plugin/base.rb +3 -2
  112. data/lib/rigor/plugin/inflector.rb +14 -5
  113. data/lib/rigor/plugin/io_boundary.rb +107 -4
  114. data/lib/rigor/plugin/loader.rb +14 -0
  115. data/lib/rigor/protection/analysis_guard.rb +60 -0
  116. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  117. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  118. data/lib/rigor/protection/discovery_seed.rb +1 -1
  119. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  120. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  121. data/lib/rigor/protection/mutator.rb +121 -18
  122. data/lib/rigor/reflection.rb +182 -30
  123. data/lib/rigor/scope/discovery_index.rb +39 -1
  124. data/lib/rigor/scope.rb +385 -4
  125. data/lib/rigor/sig_gen/classification.rb +12 -1
  126. data/lib/rigor/sig_gen/generator.rb +254 -5
  127. data/lib/rigor/sig_gen/renderer.rb +1 -6
  128. data/lib/rigor/sig_gen/writer.rb +3 -0
  129. data/lib/rigor/source/constant_path.rb +79 -2
  130. data/lib/rigor/triage/catalogue.rb +1 -1
  131. data/lib/rigor/type/combinator.rb +10 -0
  132. data/lib/rigor/type/maybe.rb +47 -0
  133. data/lib/rigor/type/refined.rb +1 -2
  134. data/lib/rigor/type/result.rb +53 -0
  135. data/lib/rigor/type.rb +2 -0
  136. data/lib/rigor/version.rb +1 -1
  137. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  138. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  139. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  140. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  141. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  142. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  147. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  148. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  150. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  151. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  152. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  153. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  154. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  156. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  157. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  158. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  159. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  160. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  163. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  164. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  165. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  166. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  167. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  168. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  169. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  170. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  171. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  172. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  173. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  174. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  175. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  176. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  177. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  178. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  179. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  180. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  181. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  182. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  183. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  184. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  185. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  186. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  187. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  188. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  189. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  190. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  191. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  192. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  193. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  194. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  195. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  196. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  197. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  198. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  200. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  201. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  202. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  203. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  204. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  205. data/sig/rigor/analysis/baseline.rbs +1 -1
  206. data/sig/rigor/environment.rbs +3 -0
  207. data/sig/rigor/inference.rbs +4 -1
  208. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  209. data/sig/rigor/reflection.rbs +3 -1
  210. data/sig/rigor/scope.rbs +25 -1
  211. data/sig/rigor/type.rbs +95 -1
  212. data/sig/rigor.rbs +16 -0
  213. metadata +69 -3
@@ -51,7 +51,7 @@ module Rigor
51
51
  # a rest positional (`*T`), or a keyword parameter is still unbound and degrades to `Dynamic[Top]`.
52
52
  #
53
53
  # See docs/adr/4-type-inference-engine.md for the broader plan.
54
- # rubocop:disable Metrics/ModuleLength
54
+ # rubocop:disable-next Metrics/ModuleLength
55
55
  module RbsDispatch
56
56
  module_function
57
57
 
@@ -144,7 +144,7 @@ module Rigor
144
144
  )
145
145
  end
146
146
 
147
- # rubocop:disable Metrics/ClassLength
147
+ # rubocop:disable-next Metrics/ClassLength
148
148
  class << self
149
149
  private
150
150
 
@@ -230,6 +230,13 @@ module Rigor
230
230
  # still resolve through Method's RBS contract. Routing here keeps reflective Method methods
231
231
  # working without forcing the carrier to collapse to a plain Nominal at construction.
232
232
  ["Method", :instance, []]
233
+ when Type::Refined, Type::Difference
234
+ # #533 — a refinement (`Refined`) or subtraction (`Difference` — `non-empty-string` is
235
+ # `String − ""`) is a precision layer over its base; RBS method lookup erases to the base
236
+ # carrier (`RUBY_VERSION != "1.0"` resolves through `String#!=` instead of declining the
237
+ # whole dispatch to Dynamic). The refinement-aware promotions (`String#upcase` →
238
+ # `uppercase-string`, …) run in their own catalog tier ABOVE this one, so they still win.
239
+ receiver_descriptor(receiver.base)
233
240
  when Type::Dynamic
234
241
  receiver_descriptor(receiver.static_facet)
235
242
  end
@@ -355,7 +362,7 @@ module Rigor
355
362
  # fallback returns the caller's type, not Object.
356
363
  self_type = self_type_override || resolved_self_type
357
364
 
358
- method_type = OverloadSelector.select(
365
+ candidates = OverloadSelector.select_candidates(
359
366
  method_definition,
360
367
  arg_types: args,
361
368
  self_type: self_type,
@@ -364,17 +371,15 @@ module Rigor
364
371
  block_required: !block_type.nil?,
365
372
  environment: environment
366
373
  )
367
- return nil unless method_type
374
+ return nil if candidates.empty?
368
375
 
369
376
  call_site = [class_name, method_name, kind]
370
- record_dispatch_provenance(method_definition, method_type, scope, call_node, call_site)
371
- full_type_vars = compose_type_vars(method_type, type_vars, args, block_type, scope, call_node, call_site)
372
-
373
- RbsTypeTranslator.translate(
374
- method_type.type.return_type,
375
- self_type: self_type,
376
- instance_type: instance_type,
377
- type_vars: full_type_vars
377
+ record_dispatch_provenance(method_definition, candidates.first, scope, call_node, call_site)
378
+ join_candidate_returns(
379
+ candidates,
380
+ self_type: self_type, instance_type: instance_type, type_vars: type_vars,
381
+ args: args, block_type: block_type, scope: scope, call_node: call_node, call_site: call_site,
382
+ alias_expander: environment.rbs_loader
378
383
  )
379
384
  end
380
385
 
@@ -400,6 +405,38 @@ module Rigor
400
405
  # The two method-level type-parameter binding positions, layered in precedence order over the
401
406
  # receiver-derived `type_vars`: the block return type first, then the argument positions (which
402
407
  # never displace an existing key). See {#compose_arg_type_vars} for the argument envelope.
408
+ # Issue #521 — more than one candidate means a `Dynamic[Top]` argument made the overloads
409
+ # indistinguishable; pinning the first answered a wrong precise type the runtime can contradict
410
+ # (`[true] * n` read as String). The join is the candidates' return union wrapped in `Dynamic`,
411
+ # not the bare union: the untyped argument may satisfy constraints (kwarg shapes, value pins)
412
+ # that exclude arms statically-indistinguishable here, so a bare union licenses the negative
413
+ # rules to fire on arms the runtime never takes — measured immediately as three false positives
414
+ # on this repository's own `lib` (`Integer(v)` joining the `exception: bool` nil arm). The
415
+ # `Dynamic[T]` carrier keeps the candidate set visible without that license (ADR-5).
416
+ # A candidate whose return does not translate leaves the join incomplete — decline (fail-soft
417
+ # to Dynamic downstream) rather than answer a join missing an arm the runtime can take.
418
+ # rubocop:disable-next Metrics/ParameterLists
419
+ def join_candidate_returns(candidates, self_type:, instance_type:, type_vars:, args:, block_type:,
420
+ scope:, call_node:, call_site:, alias_expander: nil)
421
+ returns = candidates.map do |method_type|
422
+ full_type_vars = compose_type_vars(method_type, type_vars, args, block_type, scope, call_node, call_site)
423
+ RbsTypeTranslator.translate(
424
+ method_type.type.return_type,
425
+ self_type: self_type,
426
+ instance_type: instance_type,
427
+ type_vars: full_type_vars,
428
+ alias_expander: alias_expander
429
+ )
430
+ end
431
+ return returns.first if returns.size == 1
432
+ return nil if returns.any?(&:nil?)
433
+
434
+ distinct = returns.uniq
435
+ return distinct.first if distinct.size == 1
436
+
437
+ Type::Combinator.dynamic(Type::Combinator.union(*distinct))
438
+ end
439
+
403
440
  def compose_type_vars(method_type, type_vars, args, block_type, scope, call_node, call_site)
404
441
  vars = compose_block_type_vars(method_type, type_vars, block_type)
405
442
  compose_arg_type_vars(method_type, vars, args, scope: scope, call_node: call_node,
@@ -658,7 +695,8 @@ module Rigor
658
695
  block,
659
696
  self_type: self_type,
660
697
  instance_type: instance_type,
661
- type_vars: type_vars
698
+ type_vars: type_vars,
699
+ alias_expander: environment.rbs_loader
662
700
  )
663
701
  end
664
702
 
@@ -666,7 +704,7 @@ module Rigor
666
704
  # list; some signatures use `RBS::Types::UntypedFunction` (a `(?)` block) which exposes no
667
705
  # parameter types -- we treat it as "no information" and return an empty array so the binder
668
706
  # defaults every slot.
669
- def translate_block_positional_params(block, self_type:, instance_type:, type_vars:)
707
+ def translate_block_positional_params(block, self_type:, instance_type:, type_vars:, alias_expander: nil)
670
708
  fun = block.type
671
709
  return [] unless fun.respond_to?(:required_positionals)
672
710
 
@@ -676,14 +714,13 @@ module Rigor
676
714
  param.type,
677
715
  self_type: self_type,
678
716
  instance_type: instance_type,
679
- type_vars: type_vars
717
+ type_vars: type_vars,
718
+ alias_expander: alias_expander
680
719
  )
681
720
  end
682
721
  end
683
722
  end
684
- # rubocop:enable Metrics/ClassLength
685
723
  end
686
- # rubocop:enable Metrics/ModuleLength
687
724
  end
688
725
  end
689
726
  end
@@ -48,7 +48,7 @@ module Rigor
48
48
  #
49
49
  # See docs/internal-spec/inference-engine.md (Slice 5 phase 2) and docs/adr/4-type-inference-engine.md
50
50
  # for the slice rationale.
51
- # rubocop:disable Metrics/ClassLength, Metrics/ModuleLength
51
+ # rubocop:disable-next Metrics/ClassLength, Metrics/ModuleLength
52
52
  module ShapeDispatch
53
53
  module_function
54
54
 
@@ -1920,7 +1920,6 @@ module Rigor
1920
1920
  end
1921
1921
  end
1922
1922
  end
1923
- # rubocop:enable Metrics/ClassLength, Metrics/ModuleLength
1924
1923
  end
1925
1924
  end
1926
1925
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # `Foo.instance`, where `Foo` includes the stdlib `Singleton` mixin.
9
+ #
10
+ # Ruby grows that class method through a hook — `Singleton.included(klass)` extends the class with
11
+ # `Singleton::SingletonClassMethods` — so nothing static declares it, and the upstream RBS does not close
12
+ # the gap either: `stdlib/singleton/0/singleton.rbs` declares `def self.instance: () -> instance` on the
13
+ # module itself and leaves `module SingletonClassMethods` **empty**. `Singleton.instance` is not a call
14
+ # anyone makes; `Foo.instance` is the entire point of the mixin, and it typed `Dynamic[Top]`.
15
+ #
16
+ # That is expensive out of proportion to its site count, because the value is a whole object: every
17
+ # method called on the returned instance is then dispatched on `Dynamic` too. Mastodon has 365 such
18
+ # calls (`ActivityPub::TagManager.instance`, `FeedManager.instance`, `TagManager.instance`).
19
+ #
20
+ # The rule is exact rather than heuristic — the receiver must be a `Singleton[C]` whose `C` the project
21
+ # pre-pass recorded as including `Singleton` — so a class that does not include the mixin is untouched,
22
+ # and a project that defines its own `Singleton` module is the one case that would misfire. Guarded by
23
+ # {project_singleton_shadow?}: a project-declared `Singleton` class or module wins and the tier declines,
24
+ # because then the name means something else entirely.
25
+ module SingletonMixinDispatch
26
+ MIXIN_NAMES = Ractor.make_shareable(Set["Singleton", "::Singleton"])
27
+ private_constant :MIXIN_NAMES
28
+
29
+ SELECTOR = :instance
30
+ private_constant :SELECTOR
31
+
32
+ module_function
33
+
34
+ # @param context [CallContext]
35
+ # @return [Rigor::Type, nil] `Nominal[C]` for `C.instance`, or nil to decline.
36
+ def try_dispatch(context)
37
+ return nil unless context.method_name == SELECTOR
38
+ return nil unless context.args.empty?
39
+
40
+ receiver = context.receiver
41
+ return nil unless receiver.is_a?(Type::Singleton)
42
+
43
+ scope = context.scope
44
+ return nil if scope.nil?
45
+ return nil unless includes_singleton_mixin?(scope, receiver.class_name)
46
+ return nil if project_singleton_shadow?(scope)
47
+
48
+ Type::Combinator.nominal_of(receiver.class_name)
49
+ end
50
+
51
+ def includes_singleton_mixin?(scope, class_name)
52
+ scope.includes_of(class_name).any? { |name| MIXIN_NAMES.include?(name.to_s) }
53
+ end
54
+
55
+ # True when the project declares its OWN `Singleton` class / module, in which case `include Singleton`
56
+ # names that one and the stdlib rule does not apply.
57
+ def project_singleton_shadow?(scope)
58
+ MIXIN_NAMES.any? { |name| scope.discovery.discovered_classes.key?(name.to_s.delete_prefix("::")) }
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../../type"
4
4
  require_relative "singleton_folding"
5
+ require_relative "struct_materialization"
5
6
  require_relative "member_shape_projection"
6
7
 
7
8
  module Rigor
@@ -41,6 +42,9 @@ module Rigor
41
42
  # and the reader-redefinition guard are shared with {DataFolding}.
42
43
  extend MemberShapeProjection
43
44
 
45
+ # Issue #595 / #525 — the shared materialisation test; see {StructMaterialization}.
46
+ extend StructMaterialization
47
+
44
48
  # @return [Rigor::Type, nil] the folded result, or nil to defer.
45
49
  def try_dispatch(context)
46
50
  receiver = context.receiver
@@ -258,16 +262,8 @@ module Rigor
258
262
  # read) or a FOLD-SAFE stored local (ADR-48 slice 3 — `StructFoldSafety` proved the binding is
259
263
  # never mutated / aliased / escaped in its scope).
260
264
  def foldable_receiver?(context)
261
- fresh_receiver?(context) || fold_safe_local_receiver?(context)
262
- end
263
-
264
- # A fresh receiver is the transient result of a chained call (`Point.new(1, 2).x`,
265
- # `inst.with(x: 9).y`).
266
- def fresh_receiver?(context)
267
- node = context.call_node
268
- return false if node.nil?
269
-
270
- node.receiver.is_a?(Prism::CallNode)
265
+ fresh_receiver?(context) || fold_safe_local_receiver?(context) ||
266
+ fold_safe_self_receiver?(context)
271
267
  end
272
268
 
273
269
  # A fold-safe stored receiver is a local-variable read whose name the body's fold-safe set (on the
@@ -282,6 +278,63 @@ module Rigor
282
278
  scope.struct_fold_safe?(receiver.name)
283
279
  end
284
280
 
281
+ # Issue #525 — the in-body half. A member read written with no receiver (or `self.member`) inside a
282
+ # method the CALLER resolved on a fold-safe struct receiver: `Line.new("a", 2).shout` re-types
283
+ # `shout`'s body with `self_type` = the caller's `StructInstance`, and the implicit-self `text` read
284
+ # arrives here with the right carrier but no receiver node, so neither of the two arms above can see
285
+ # that the caller's receiver was foldable.
286
+ #
287
+ # The caller records that grant as the `:self` sentinel in the body scope's fold-safe set — a
288
+ # `Set[Symbol]` no Ruby local can collide with, since `self` is a keyword. The grant is issued only
289
+ # for a `StructInstance` carrier whose body {Inference::StructFoldSafety.self_fold_safe_body?}
290
+ # cleared, and it is tied HERE to the exact carrier it was issued for: a body scope reached by any
291
+ # other route types its `self` differently, and the read declines rather than fold a map that
292
+ # belongs to a different instance.
293
+ def fold_safe_self_receiver?(context)
294
+ node = context.call_node
295
+ return false if node.nil?
296
+
297
+ receiver_node = node.receiver
298
+ return false unless receiver_node.nil? || receiver_node.is_a?(Prism::SelfNode)
299
+
300
+ scope = context.scope
301
+ return false unless scope&.struct_fold_safe?(:self)
302
+
303
+ same_carrier?(context.receiver, scope.self_type)
304
+ end
305
+
306
+ # Carrier identity for the `:self` grant. `StructInstance` carries structural equality over exactly
307
+ # the two fields that matter here (`value_fields :members, :class_name`), so `==` is both the tighter
308
+ # comparison and allocation-free — no display string is built.
309
+ def same_carrier?(receiver, self_type)
310
+ receiver.is_a?(Type::StructInstance) && receiver == self_type
311
+ end
312
+
313
+ # A fresh receiver is the result of a chained call that MATERIALISED the struct — `Point.new(1, 2).x`,
314
+ # `Point[1, 2].x`, `inst.with(x: 9).y`. Not merely a chained call: slices 1-2 read "chained" as
315
+ # "cannot have been mutated between materialisation and the read", and a method that hands back its
316
+ # own receiver falsifies that (issue #595):
317
+ #
318
+ # Line = Struct.new(:text) do
319
+ # def with_text(v) = (self.text = v; self)
320
+ # def dup_self = self
321
+ # end
322
+ # x = Line.new("a"); x.text = "z"
323
+ # x.dup_self.text # the chain returns the SAME, mutated object
324
+ # Line.new("a").with_text("z").text # so does the fluent spelling
325
+ #
326
+ # Both folded the construction-time `"a"` against a runtime `"z"` — a wrong precise value on
327
+ # ordinary fluent-builder Ruby. `infer_user_method_return` types a body under a fixed `self_type`,
328
+ # so the setter never poisons the returned `self`, and the freshness test never asked the callee
329
+ # what it returns. Restricting the test to the shapes this module itself materialises closes the
330
+ # family in one place: any other callee refuses, whatever it does internally.
331
+ def fresh_receiver?(context)
332
+ node = context.call_node
333
+ return false if node.nil?
334
+
335
+ materialization_call?(node.receiver, context.receiver, context.scope)
336
+ end
337
+
285
338
  # ADR-48 slice 4 — precise mutated-member re-typing. After a `local.member = v` setter on a
286
339
  # fold-safe `StructInstance` local, rebind the local to a `StructInstance` with that member replaced
287
340
  # by the assigned type, so a later `local.member` read folds to the assigned value (and a sibling
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../../type"
6
+ require_relative "../../source/constant_path"
7
+
8
+ module Rigor
9
+ module Inference
10
+ module MethodDispatcher
11
+ # Issue #595 / #525 — the ONE answer to "did this receiver expression just BUILD the struct?", shared by
12
+ # the two gates that must not disagree about it: {StructFolding#fresh_receiver?}, which decides whether a
13
+ # direct member read folds, and `ExpressionTyper`'s caller-side `:self`-grant arm, which decides whether a
14
+ # whole method body may fold its receiverless member reads.
15
+ #
16
+ # They had forked answers once — the grant arm was tightened for #591 while the read gate still trusted
17
+ # any chained call — and that is exactly how the composed `x.dup_self.with(indent: 9).shout` shape
18
+ # survived: a correctly whitelisted `.with` fold firing off a receiver the other gate wrongly called
19
+ # fresh. One implementation is the fix for the class of divergence, not just for that shape — and the
20
+ # module takes NO resolver parameter for the same reason: a lookup each consumer supplies is a fork
21
+ # waiting to happen, so the `.with` guard resolves through `Scope` itself (#598 review).
22
+ #
23
+ # Scope is this module's only entry: `StructFolding` `extend`s it, and since `module_function` copies
24
+ # arrive private through `extend`, the predicate stays callable under this module's own name.
25
+ module StructMaterialization
26
+ module_function
27
+
28
+ # Issue #595 / #525 — the ONE materialisation test. Both places that must answer "was this receiver
29
+ # expression's struct newly built?" go through it: this module's direct member-read gate above, and
30
+ # `ExpressionTyper`'s caller-side `:self`-grant arm. They had forked answers once (the grant arm was
31
+ # tightened for #591 while this one still trusted any chained call), which is exactly how the
32
+ # composed `x.dup_self.with(indent: 9).shout` shape survived — a correctly whitelisted `.with` fold
33
+ # firing off a receiver the other gate should never have called fresh.
34
+ #
35
+ #
36
+ # @param node [Prism::Node, nil] the receiver EXPRESSION.
37
+ # @param receiver [Rigor::Type, nil] the carrier the expression produced.
38
+ # @param scope [Rigor::Scope, nil]
39
+ def materialization_call?(node, receiver, scope)
40
+ return false unless node.is_a?(Prism::CallNode)
41
+
42
+ case node.name
43
+ when :new, :[] then struct_class_expression?(node.receiver, scope)
44
+ when :with then !hand_written_with?(receiver, scope)
45
+ else false
46
+ end
47
+ end
48
+
49
+ # `.with` copies the receiver into a new instance — unless the struct wrote its own, which is free to
50
+ # return `self` and would reopen the very hole above.
51
+ #
52
+ # An ANONYMOUS carrier cannot have one: a `StructInstance` with no class name came from a blockless
53
+ # `Struct.new(…)` (the block form defers in {fold_struct_new} before any instance exists), and a
54
+ # blockless factory defines nothing but the generated accessors. So it is the NAMED carriers that
55
+ # need the lookup — and a named one with no scope to ask refuses, since unproven is not fresh.
56
+ def hand_written_with?(receiver, scope)
57
+ return true unless receiver.is_a?(Type::StructInstance)
58
+
59
+ class_name = receiver.class_name
60
+ return false if class_name.nil?
61
+ return true if scope.nil?
62
+
63
+ # The ANCESTOR walk, not the own-class table: a `with` contributed by an included module is just as
64
+ # free to return `self`. Both consumers reach the identical answer because there is one lookup here
65
+ # rather than a parameter each of them supplies (#598 review).
66
+ !scope.user_def_through_ancestors(class_name, :with).first.nil?
67
+ end
68
+
69
+ # An expression naming the struct class itself. All three definition forms ADR-48 supports qualify,
70
+ # because each of them really does construct: a constant whose member layout the project side-table
71
+ # recorded (`Point.new(…)`), a local holding a `StructClass` carrier (`c = Struct.new(:x, :y)` then
72
+ # `c.new(…)`), and the inline factory chain (`Struct.new(:x, :y).new(…)`). The local arm reads the
73
+ # binding rather than re-typing the node, so it costs one hash read and cannot re-enter dispatch.
74
+ def struct_class_expression?(node, scope)
75
+ return false if scope.nil?
76
+
77
+ case node
78
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
79
+ name = Source::ConstantPath.qualified_name_or_nil(node)
80
+ !name.nil? && !scope.struct_member_layout(name).nil?
81
+ when Prism::LocalVariableReadNode
82
+ scope.local(node.name).is_a?(Type::StructClass)
83
+ when Prism::CallNode
84
+ inline_struct_factory?(node)
85
+ else false
86
+ end
87
+ end
88
+
89
+ # `Struct.new(:a, :b)` / `Data.define(:a, :b)` written inline as the receiver of the `.new` that
90
+ # materialises the instance.
91
+ def inline_struct_factory?(node)
92
+ return false unless %i[new define].include?(node.name)
93
+
94
+ receiver = node.receiver
95
+ case receiver
96
+ when Prism::ConstantReadNode then %i[Struct Data].include?(receiver.name)
97
+ when Prism::ConstantPathNode then receiver.parent.nil? && %i[Struct Data].include?(receiver.name)
98
+ else false
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # The last resolution tier for a `Dynamic` receiver: the handful of `BasicObject` / `Object` / `Kernel`
9
+ # methods whose return type does not depend on who the receiver is.
10
+ #
11
+ # `x.nil?` is `bool` whatever `x` is. Until this tier existed it was `Dynamic[Top]`, because every
12
+ # preceding tier needs a receiver it can name and the typer then fell through to the Dynamic-origin
13
+ # propagation. The same held for `is_a?`, `respond_to?`, `!` and the rest of the set below — a
14
+ # measurable share of the opaque expressions on a real codebase, closed by a fixed table rather than
15
+ # by knowing anything about the receiver
16
+ # (`docs/notes/20260831-self-check-type-coverage-audit.md` § Finding 2: +2.27 points of precision on this
17
+ # repository's own `lib`, with zero new diagnostics).
18
+ #
19
+ # **Where it sits.** After every real tier has declined, so a receiver Rigor can name always resolves
20
+ # through RBS first — `"".frozen?` is answered by `String`'s signature, not by this table. It only ever
21
+ # replaces a `Dynamic[Top]` that the engine was about to produce anyway.
22
+ #
23
+ # **What is deliberately NOT here**, each measured rather than argued (same note):
24
+ #
25
+ # - `class` — `Nominal[Class]` erases the singleton. `p.class.dynamic_returns` on a plugin instance is
26
+ # real code in this repository; folding the receiver's class to the bare `Class` nominal produced 13
27
+ # `call.undefined-method` false positives on `lib` alone. An ADR-5 violation bought for +0.14 points.
28
+ # - `==` / `!=` / `eql?` — the same failure at a much larger blast radius. `==` is the most commonly
29
+ # overridden method in Ruby, and an overridden one is free to return anything.
30
+ # - `to_s` — receiver-independent in principle, but enabling it surfaces the RBS
31
+ # `Array#[](Range) -> Array[T]?` optional-return noise on `x.to_s.split("::")[0...-1]` (7
32
+ # `call.possible-nil-receiver` on `lib`). Not this tier's defect, but not this tier's to ship either:
33
+ # it goes in once that noise has an answer.
34
+ # - `freeze` / `dup` / `clone` / `itself` / `tap` — receiver-independent in a different way (they
35
+ # return the receiver), so on a `Dynamic` receiver they gain nothing.
36
+ module UniversalObjectDispatch
37
+ # `bool` as the engine spells it — `Constant[true] | Constant[false]`, the union
38
+ # `RbsTypeTranslator` folds RBS's `bool` to. Spelling it `Nominal[TrueClass] | Nominal[FalseClass]`
39
+ # instead type-checks but is not the same value: it fails to match a `-> bool` declaration and
40
+ # produced 17 spurious `def.return-type-mismatch` warnings when this tier was first prototyped.
41
+ BOOL = Type::Combinator.union(
42
+ Type::Combinator.constant_of(true),
43
+ Type::Combinator.constant_of(false)
44
+ ).freeze
45
+ private_constant :BOOL
46
+
47
+ # Selector => return type, for selectors defined on `BasicObject` / `Object` / `Kernel` whose result
48
+ # is a function of the language, not of the receiver's class:
49
+ #
50
+ # - the type predicates (`nil?`, `is_a?` and its aliases, `respond_to?`, `frozen?`) and the identity
51
+ # comparison `equal?`, which `BasicObject` defines as object identity;
52
+ # - `!`, which Ruby's own semantics constrain to `true` / `false`;
53
+ # - `inspect`, whose `Object` contract is a `String` representation;
54
+ # - `hash` and `object_id`, both `Integer` by contract — `hash` because `Hash` requires it.
55
+ RETURN_TYPES = Ractor.make_shareable({
56
+ :nil? => BOOL,
57
+ :is_a? => BOOL,
58
+ :kind_of? => BOOL,
59
+ :instance_of? => BOOL,
60
+ :respond_to? => BOOL,
61
+ :equal? => BOOL,
62
+ :frozen? => BOOL,
63
+ :! => BOOL,
64
+ :inspect => Type::Combinator.nominal_of("String"),
65
+ :hash => Type::Combinator.nominal_of("Integer"),
66
+ :object_id => Type::Combinator.nominal_of("Integer")
67
+ })
68
+
69
+ module_function
70
+
71
+ # @param context [CallContext]
72
+ # @return [Rigor::Type, nil] the receiver-independent return type, or nil to decline.
73
+ def try_dispatch(context)
74
+ return nil unless context.receiver.is_a?(Type::Dynamic)
75
+
76
+ RETURN_TYPES[context.method_name]
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end