rigortype 0.1.19 → 0.2.1

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 (197) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -6
  3. data/data/core_overlay/numeric.rbs +33 -0
  4. data/data/core_overlay/pathname.rbs +25 -0
  5. data/data/core_overlay/string_scanner.rbs +28 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +473 -0
  7. data/data/vendored_gem_sigs/ast/ast.rbs +130 -0
  8. data/data/vendored_gem_sigs/bcrypt/bcrypt.rbs +47 -0
  9. data/data/vendored_gem_sigs/bundler/bundler.rbs +238 -0
  10. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +34 -0
  11. data/data/vendored_gem_sigs/did_you_mean/did_you_mean_extras.rbs +34 -0
  12. data/data/vendored_gem_sigs/idn-ruby/idn.rbs +54 -0
  13. data/data/vendored_gem_sigs/mysql2/client.rbs +55 -0
  14. data/data/vendored_gem_sigs/mysql2/error.rbs +5 -0
  15. data/data/vendored_gem_sigs/mysql2/result.rbs +31 -0
  16. data/data/vendored_gem_sigs/mysql2/statement.rbs +5 -0
  17. data/data/vendored_gem_sigs/nokogiri/nokogiri.rbs +2332 -0
  18. data/data/vendored_gem_sigs/nokogiri/nokogiri_html5.rbs +47 -0
  19. data/data/vendored_gem_sigs/pg/pg.rbs +212 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +44 -0
  21. data/data/vendored_gem_sigs/redis/errors.rbs +50 -0
  22. data/data/vendored_gem_sigs/redis/future.rbs +5 -0
  23. data/data/vendored_gem_sigs/redis/redis.rbs +348 -0
  24. data/data/vendored_gem_sigs/redis/redis_extras.rbs +130 -0
  25. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +226 -0
  26. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  29. data/lib/rigor/analysis/check_rules.rb +492 -71
  30. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  31. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  32. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  33. data/lib/rigor/analysis/fact_store.rb +5 -4
  34. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
  36. data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
  37. data/lib/rigor/analysis/runner.rb +17 -6
  38. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  39. data/lib/rigor/analysis/worker_session.rb +10 -14
  40. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  41. data/lib/rigor/cache/store.rb +5 -3
  42. data/lib/rigor/cli/annotate_command.rb +28 -7
  43. data/lib/rigor/cli/baseline_command.rb +4 -3
  44. data/lib/rigor/cli/check_command.rb +138 -16
  45. data/lib/rigor/cli/coverage_command.rb +138 -31
  46. data/lib/rigor/cli/coverage_mutation.rb +149 -0
  47. data/lib/rigor/cli/coverage_scan.rb +57 -0
  48. data/lib/rigor/cli/explain_command.rb +2 -0
  49. data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
  50. data/lib/rigor/cli/fused_protection_report.rb +76 -0
  51. data/lib/rigor/cli/lsp_command.rb +3 -7
  52. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  53. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  54. data/lib/rigor/cli/options.rb +9 -0
  55. data/lib/rigor/cli/plugins_command.rb +2 -1
  56. data/lib/rigor/cli/protection_renderer.rb +63 -0
  57. data/lib/rigor/cli/protection_report.rb +68 -0
  58. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  59. data/lib/rigor/cli/trace_command.rb +2 -1
  60. data/lib/rigor/cli/triage_command.rb +2 -1
  61. data/lib/rigor/cli/type_of_command.rb +1 -1
  62. data/lib/rigor/cli/type_scan_command.rb +2 -1
  63. data/lib/rigor/cli.rb +3 -2
  64. data/lib/rigor/config_audit.rb +152 -0
  65. data/lib/rigor/configuration/dependencies.rb +2 -4
  66. data/lib/rigor/configuration.rb +57 -7
  67. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  68. data/lib/rigor/environment/class_registry.rb +4 -3
  69. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  70. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  71. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  72. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  73. data/lib/rigor/environment/rbs_loader.rb +76 -5
  74. data/lib/rigor/environment.rb +66 -8
  75. data/lib/rigor/flow_contribution/fact.rb +1 -1
  76. data/lib/rigor/flow_contribution.rb +3 -5
  77. data/lib/rigor/inference/acceptance.rb +17 -9
  78. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  79. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  80. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  81. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  82. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  83. data/lib/rigor/inference/expression_typer.rb +20 -28
  84. data/lib/rigor/inference/hkt_body.rb +8 -11
  85. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  86. data/lib/rigor/inference/hkt_registry.rb +10 -11
  87. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  88. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +169 -24
  89. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  90. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  91. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  92. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  93. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  94. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  95. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  96. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
  97. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  98. data/lib/rigor/inference/method_dispatcher.rb +40 -48
  99. data/lib/rigor/inference/mutation_widening.rb +5 -11
  100. data/lib/rigor/inference/narrowing.rb +14 -16
  101. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  102. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  103. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  104. data/lib/rigor/inference/protection_scanner.rb +86 -0
  105. data/lib/rigor/inference/scope_indexer.rb +129 -55
  106. data/lib/rigor/inference/statement_evaluator.rb +271 -114
  107. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  108. data/lib/rigor/inference/synthetic_method.rb +7 -7
  109. data/lib/rigor/language_server/completion_provider.rb +6 -12
  110. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  111. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  112. data/lib/rigor/language_server/hover_provider.rb +2 -3
  113. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  114. data/lib/rigor/language_server/server.rb +9 -17
  115. data/lib/rigor/language_server.rb +4 -5
  116. data/lib/rigor/plugin/base.rb +10 -8
  117. data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
  118. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  119. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  120. data/lib/rigor/plugin/macro.rb +4 -5
  121. data/lib/rigor/plugin/manifest.rb +45 -66
  122. data/lib/rigor/plugin/registry.rb +6 -7
  123. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  124. data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
  125. data/lib/rigor/protection/mutation_scanner.rb +180 -0
  126. data/lib/rigor/protection/mutator.rb +267 -0
  127. data/lib/rigor/protection/test_suite_oracle.rb +68 -0
  128. data/lib/rigor/rbs_extended.rb +24 -36
  129. data/lib/rigor/reflection.rb +4 -7
  130. data/lib/rigor/scope/discovery_index.rb +14 -2
  131. data/lib/rigor/scope.rb +54 -11
  132. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  133. data/lib/rigor/sig_gen/writer.rb +40 -2
  134. data/lib/rigor/signature_path_audit.rb +92 -0
  135. data/lib/rigor/source/constant_path.rb +62 -0
  136. data/lib/rigor/source.rb +1 -0
  137. data/lib/rigor/type/bound_method.rb +2 -11
  138. data/lib/rigor/type/combinator.rb +16 -3
  139. data/lib/rigor/type/constant.rb +2 -11
  140. data/lib/rigor/type/data_class.rb +2 -11
  141. data/lib/rigor/type/data_instance.rb +2 -11
  142. data/lib/rigor/type/hash_shape.rb +2 -11
  143. data/lib/rigor/type/integer_range.rb +2 -11
  144. data/lib/rigor/type/intersection.rb +2 -11
  145. data/lib/rigor/type/nominal.rb +2 -11
  146. data/lib/rigor/type/plain_lattice.rb +37 -0
  147. data/lib/rigor/type/refined.rb +72 -13
  148. data/lib/rigor/type/singleton.rb +2 -11
  149. data/lib/rigor/type/struct_class.rb +75 -0
  150. data/lib/rigor/type/struct_instance.rb +93 -0
  151. data/lib/rigor/type/tuple.rb +5 -15
  152. data/lib/rigor/type.rb +2 -0
  153. data/lib/rigor/version.rb +1 -1
  154. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  155. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  156. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
  157. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  159. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  161. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  162. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
  163. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  164. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
  165. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  166. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  167. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  168. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  171. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  172. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
  173. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  174. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  175. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
  176. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  179. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  182. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  184. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  185. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  186. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +19 -14
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  189. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  190. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  191. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
  195. data/sig/rigor/scope.rbs +9 -1
  196. data/sig/rigor/type.rbs +36 -1
  197. metadata +49 -1
@@ -45,8 +45,9 @@ module Rigor
45
45
  # machinery works without duplication: `Tuple[Integer, String]`
46
46
  # dispatches as `Array[Integer | String]`, and
47
47
  # `HashShape{a: Integer}` dispatches as `Hash[Symbol, Integer]`.
48
- # Tuple-aware refinements (e.g., `tuple[0]` returning the precise
49
- # member) are deferred to Slice 5 phase 2.
48
+ # Tuple/HashShape element precision (e.g., `tuple[0]` returning
49
+ # the precise member) is handled by the preceding `ShapeDispatch`
50
+ # tier.
50
51
  #
51
52
  # Remaining limitations:
52
53
  #
@@ -107,17 +108,11 @@ module Rigor
107
108
  # @return [Rigor::Type, nil] inferred return type, or `nil`
108
109
  # when no rule resolves (no class name, no method, dispatch
109
110
  # on a Top/Dynamic[Top] receiver, etc.).
110
- # @param scope [Rigor::Scope, nil] when supplied, enables
111
- # ADR-43 RBS-complete-ancestor resolution: a call on a
112
- # Ruby-source subclass not known to RBS, whose discovered
113
- # superclass chain reaches an allow-listed RBS-complete
114
- # ancestor (e.g. `Rigor::Plugin::Base`), resolves against
115
- # that ancestor's RBS. `nil` (the default for every caller
116
- # that does not thread a scope) keeps the legacy behaviour —
117
- # such an inherited call stays unresolved and degrades to
118
- # `Dynamic[Top]`, which is the false-positive-safe default
119
- # for the open hierarchies (`< ActionController::Base`, …)
120
- # the allow-list deliberately excludes.
111
+ # @param scope [Rigor::Scope, nil] when supplied, enables ADR-43
112
+ # RBS-complete-ancestor resolution against
113
+ # `ALLOWED_RBS_COMPLETE_ANCESTORS`. `nil` keeps inherited calls
114
+ # unresolved (`Dynamic[Top]`) the FP-safe default for open
115
+ # hierarchies (`< ActionController::Base`, …).
121
116
  def try_dispatch(context)
122
117
  environment = context.environment
123
118
  return nil if environment.nil?
@@ -249,15 +244,8 @@ module Rigor
249
244
  ["Array", :instance, tuple_type_args(receiver)]
250
245
  when Type::HashShape
251
246
  ["Hash", :instance, hash_shape_type_args(receiver)]
252
- when Type::DataInstance
253
- # ADR-48 — project a member-instance carrier to its tagging
254
- # class (or the `Data` supertype) so non-member calls
255
- # (`inspect`, `==`, `frozen?`, ...) resolve through RBS
256
- # rather than mis-firing undefined-method. Member reads were
257
- # already folded by DataFolding above this tier.
258
- [receiver.class_name || "Data", :instance, []]
259
- when Type::DataClass
260
- [receiver.class_name || "Data", :singleton, []]
247
+ when Type::DataInstance, Type::DataClass, Type::StructInstance, Type::StructClass
248
+ member_carrier_descriptor(receiver)
261
249
  when Type::BoundMethod
262
250
  # `BoundMethod` is a precision-bearing alias for
263
251
  # `Nominal[Method]`: it carries the
@@ -275,6 +263,20 @@ module Rigor
275
263
  end
276
264
  end
277
265
 
266
+ # ADR-48 — project a `Data`/`Struct` member carrier to its tagging
267
+ # class (or the `Data`/`Struct` supertype) so non-member calls
268
+ # (`inspect`, `==`, `frozen?`, ...) resolve through RBS rather than
269
+ # mis-firing undefined-method. Precise member reads were already
270
+ # folded by DataFolding / StructFolding above this tier.
271
+ def member_carrier_descriptor(receiver)
272
+ case receiver
273
+ when Type::DataInstance then [receiver.class_name || "Data", :instance, []]
274
+ when Type::DataClass then [receiver.class_name || "Data", :singleton, []]
275
+ when Type::StructInstance then [receiver.class_name || "Struct", :instance, []]
276
+ when Type::StructClass then [receiver.class_name || "Struct", :singleton, []]
277
+ end
278
+ end
279
+
278
280
  def tuple_type_args(tuple)
279
281
  return [] if tuple.elements.empty?
280
282
 
@@ -81,6 +81,7 @@ module Rigor
81
81
  sum: :tuple_sum,
82
82
  min: :tuple_min,
83
83
  max: :tuple_max,
84
+ minmax: :tuple_minmax_pair,
84
85
  sort: :tuple_sort,
85
86
  reverse: :tuple_reverse,
86
87
  to_a: :tuple_to_a,
@@ -99,9 +100,15 @@ module Rigor
99
100
  index: :tuple_find_index,
100
101
  find_index: :tuple_find_index,
101
102
  rindex: :tuple_rindex,
102
- flatten: :tuple_flatten
103
+ flatten: :tuple_flatten,
104
+ join: :tuple_join
103
105
  }.freeze
104
106
 
107
+ # Byte cap on a folded `tuple.join` result — a huge tuple times a
108
+ # long separator must not materialise an unbounded `Constant`.
109
+ TUPLE_JOIN_BYTE_LIMIT = 4096
110
+ private_constant :TUPLE_JOIN_BYTE_LIMIT
111
+
105
112
  HASH_SHAPE_HANDLERS = {
106
113
  size: :hash_size,
107
114
  length: :hash_size,
@@ -573,8 +580,17 @@ module Rigor
573
580
  %i[lowercase upcase] => :uppercase_string,
574
581
  %i[uppercase upcase] => :refined_self,
575
582
  %i[uppercase downcase] => :lowercase_string,
583
+ # `numeric-string` is the full Ruby numeric-literal
584
+ # grammar (since the predicate delegates to the
585
+ # parser). `#downcase` preserves it — lowercasing a
586
+ # literal (hex digits, `0X` / `E` prefixes) yields a
587
+ # valid lowercase literal — but `#upcase` does NOT:
588
+ # the rational / imaginary suffixes are lowercase-only
589
+ # (`"1r".upcase == "1R"` is not a literal), so `upcase`
590
+ # drops to the plain base `String` — still sound (the
591
+ # result is a String), just no longer numeric.
576
592
  %i[numeric downcase] => :refined_self,
577
- %i[numeric upcase] => :refined_self,
593
+ %i[numeric upcase] => :base_string,
578
594
  # Digit-only strings are case-invariant; the prefix
579
595
  # letters in `0o…` / `0x…` are accepted by the
580
596
  # predicate in either case so the predicate-subset
@@ -587,19 +603,19 @@ module Rigor
587
603
  %i[hex_int downcase] => :refined_self,
588
604
  %i[hex_int upcase] => :refined_self,
589
605
  # v0.1.1 Track 1 slice 2 — `to_i` / `to_int` on a
590
- # known digit-only string. `decimal-int-string`
591
- # (`/\A\d+\z/`) and `numeric-string` (Rigor's
592
- # numeric-string predicate, ASCII digits) are
593
- # predicates over digit-only strings, so the parse
594
- # is total over the carrier domain and the result
595
- # is always `>= 0`. `non-negative-int` is the
596
- # tightest carrier that captures both the lower
597
- # bound and the integer-ness without inventing a
598
- # narrower carrier.
599
- %i[decimal_int to_i] => :non_negative_int,
600
- %i[decimal_int to_int] => :non_negative_int,
601
- %i[numeric to_i] => :non_negative_int,
602
- %i[numeric to_int] => :non_negative_int
606
+ # `decimal-int-string` parses to an `Integer`. The
607
+ # carrier is `universal_int`, NOT `non-negative-int`:
608
+ # the predicate `/\A-?\d+\z/` admits a leading sign, so
609
+ # `"-7"` is a valid decimal-int-string and
610
+ # `"-7".to_i == -7 < 0`. `String#to_i` is total (never
611
+ # raises), so the projection is sound — just signed.
612
+ # `numeric-string` is deliberately NOT projected to
613
+ # `to_i` at all: it now spans the full numeric-literal
614
+ # grammar, so a `"1.5"` / `"2i"` inhabitant has a
615
+ # fractional or non-Integer parse — it falls through to
616
+ # the RBS `Integer`.
617
+ %i[decimal_int to_i] => :universal_int,
618
+ %i[decimal_int to_int] => :universal_int
603
619
  })
604
620
  private_constant :REFINED_STRING_PROJECTIONS
605
621
 
@@ -624,6 +640,8 @@ module Rigor
624
640
  when :uppercase_string then Type::Combinator.uppercase_string
625
641
  when :lowercase_string then Type::Combinator.lowercase_string
626
642
  when :non_negative_int then Type::Combinator.non_negative_int
643
+ when :universal_int then Type::Combinator.universal_int
644
+ when :base_string then refined.base
627
645
  end
628
646
  end
629
647
 
@@ -791,6 +809,37 @@ module Rigor
791
809
  Type::Combinator.constant_of(values.sum)
792
810
  end
793
811
 
812
+ # `tuple.join(sep = "")` — fold to the joined `Constant[String]`
813
+ # when every element is a `Constant` (its `to_s` is deterministic
814
+ # for the scalar value classes) and the separator is absent or a
815
+ # `Constant[String]`. Capped at `TUPLE_JOIN_BYTE_LIMIT`.
816
+ def tuple_join(tuple, _method_name, args)
817
+ sep = tuple_join_separator(args)
818
+ return nil if sep.nil?
819
+
820
+ values = constant_values(tuple.elements)
821
+ return nil if values.nil?
822
+
823
+ result = values.join(sep)
824
+ return nil if result.bytesize > TUPLE_JOIN_BYTE_LIMIT
825
+
826
+ Type::Combinator.constant_of(result)
827
+ rescue StandardError
828
+ nil
829
+ end
830
+
831
+ # The join separator: `""` for the no-arg form, the value of a
832
+ # single `Constant[String]` arg, or `nil` to decline.
833
+ def tuple_join_separator(args)
834
+ return "" if args.empty?
835
+ return nil unless args.size == 1
836
+
837
+ arg = args.first
838
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(String)
839
+
840
+ arg.value
841
+ end
842
+
794
843
  # `tuple.min` / `tuple.max` — fold when every element is
795
844
  # a `Constant` whose values share a Ruby-comparable
796
845
  # domain. Empty tuples fold to `Constant[nil]`.
@@ -815,6 +864,32 @@ module Rigor
815
864
  nil
816
865
  end
817
866
 
867
+ # `tuple.minmax` — the `[min, max]` pair as a 2-slot
868
+ # `Tuple[Constant[min], Constant[max]]`, mirroring the
869
+ # `Range#minmax` fold. Every element must be a `Constant`
870
+ # and the values must Ruby-compare; an empty tuple folds to
871
+ # `Tuple[nil, nil]` (Ruby's `[].minmax`), incomparable
872
+ # mixed-class values decline.
873
+ def tuple_minmax_pair(tuple, _method_name, args)
874
+ return nil unless args.empty?
875
+
876
+ if tuple.elements.empty?
877
+ nil_const = Type::Combinator.constant_of(nil)
878
+ return Type::Combinator.tuple_of(nil_const, nil_const)
879
+ end
880
+
881
+ values = constant_values(tuple.elements)
882
+ return nil if values.nil?
883
+
884
+ low, high = values.minmax
885
+ Type::Combinator.tuple_of(
886
+ Type::Combinator.constant_of(low),
887
+ Type::Combinator.constant_of(high)
888
+ )
889
+ rescue StandardError
890
+ nil
891
+ end
892
+
818
893
  # `tuple.sort` — every element must be a `Constant` and
819
894
  # the values must Ruby-compare. The result is a Tuple
820
895
  # with the same elements in sorted order. Comparison
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+ require_relative "singleton_folding"
5
+ require_relative "member_shape_projection"
6
+
7
+ module Rigor
8
+ module Inference
9
+ module MethodDispatcher
10
+ # ADR-48 Struct follow-up — `Struct.new` value folding, the mutable
11
+ # sibling of {DataFolding}. Same fully-decidable-shape, degrade-on-any-
12
+ # uncertainty discipline, with one extra premise the immutable `Data`
13
+ # path does not need: **mutation soundness.**
14
+ #
15
+ # A `Struct` instance is mutable (`s.x = v`, `s[:x] = v`, escape), so a
16
+ # member map bound to a variable can be invalidated by a later write.
17
+ # This slice (ADR-48 slices 1+2 in the sound *transient* form) folds a
18
+ # member read ONLY off a **fresh** instance — the transient receiver of
19
+ # a `.new(...).x` / `.with(...).x` chain, which provably cannot have
20
+ # been mutated between materialisation and the read. A member read off
21
+ # a *stored* binding degrades to `Dynamic[top]` rather than fold a
22
+ # possibly-stale value. Promoting the fold to mutation-free bound
23
+ # locals is the deferred slice 3 (relax the fresh-receiver gate to a
24
+ # fold-safe-local scan); precise mutated-member re-typing is slice 4.
25
+ #
26
+ # Responsibilities:
27
+ #
28
+ # 1. `Struct.new(:x, :y [, keyword_init: <bool>])` on a
29
+ # `Singleton[Struct]` receiver, literal-Symbol members, NO block ->
30
+ # `StructClass{members:, keyword_init:}`. A block / non-literal
31
+ # members defer.
32
+ # 2. `.new` / `.[]` on a `StructClass` (or a `Singleton[Point]` with a
33
+ # recorded struct layout) -> a `StructInstance`, the member map built
34
+ # from the call's arguments (positional or keyword per the class's
35
+ # `keyword_init`). A form / arity mismatch degrades to `Dynamic[top]`
36
+ # rather than a wrong member map. `.members` on the class folds.
37
+ # 3. member reads + `[]` / `to_h` / `deconstruct` / `deconstruct_keys`
38
+ # / `members` / `with` on a *fresh* `StructInstance` -> the precise
39
+ # projected type; member *setters* (`s.x = v`) return the assigned
40
+ # value type. Unhandled / stored-receiver calls return nil so the
41
+ # pipeline projects the instance to its nominal through RbsDispatch.
42
+ #
43
+ # See docs/adr/48-data-struct-value-folding.md § "Struct follow-up".
44
+ module StructFolding
45
+ module_function
46
+
47
+ # The `[]` / `to_h` / `deconstruct` / `members` / `with` projections
48
+ # and the reader-redefinition guard are shared with {DataFolding}.
49
+ extend MemberShapeProjection
50
+
51
+ # @return [Rigor::Type, nil] the folded result, or nil to defer.
52
+ def try_dispatch(context)
53
+ receiver = context.receiver
54
+
55
+ return fold_struct_new(context) if SingletonFolding.receiver?(receiver, "Struct")
56
+
57
+ case receiver
58
+ when Type::StructClass
59
+ dispatch_struct_class(receiver, context)
60
+ when Type::StructInstance
61
+ fold_instance(receiver, context)
62
+ when Type::Singleton
63
+ fold_named_new(receiver, context)
64
+ end
65
+ end
66
+
67
+ # A `Struct.new`-defined class assigned to a constant (or a
68
+ # `class Point < Struct.new(...)` subclass) is canonicalised by the
69
+ # engine to `Singleton[Point]`, not a `StructClass` — so its member
70
+ # layout is read from the project side-table the scope indexer built
71
+ # (`Scope#struct_member_layout`) rather than from the receiver
72
+ # carrier.
73
+ def fold_named_new(singleton, context)
74
+ scope = context.scope
75
+ return nil if scope.nil?
76
+
77
+ layout = scope.struct_member_layout(singleton.class_name)
78
+ return nil if layout.nil?
79
+
80
+ materialize_instance(layout[:members], layout[:keyword_init], singleton.class_name, context)
81
+ end
82
+
83
+ # --- 1. Struct.new(:x, :y) --------------------------------------
84
+
85
+ def fold_struct_new(context)
86
+ return nil unless context.method_name == :new
87
+ # Block-form (`Struct.new(:x) do ... end`) defers — the named
88
+ # constant/subclass forms still fold via the layout side-table.
89
+ return nil unless context.block_type.nil?
90
+
91
+ parsed = parse_struct_new_args(context.args)
92
+ return nil if parsed.nil?
93
+
94
+ Type::Combinator.struct_class_of(members: parsed[:members], keyword_init: parsed[:keyword_init])
95
+ end
96
+
97
+ # Parses `Struct.new`'s arguments into `{ members:, keyword_init: }`,
98
+ # or nil when any argument is non-conforming (a dynamic member name,
99
+ # an unexpected trailing keyword). Handles the optional leading String
100
+ # class name and the trailing `keyword_init:` option hash.
101
+ def parse_struct_new_args(args)
102
+ rest = args.dup
103
+ keyword_init = false
104
+
105
+ if rest.last.is_a?(Type::HashShape)
106
+ options = rest.pop
107
+ return nil unless struct_options_hash?(options)
108
+
109
+ value = options.pairs[:keyword_init]
110
+ keyword_init = value.is_a?(Type::Constant) && value.value == true
111
+ end
112
+
113
+ # Optional leading String name: `Struct.new("Point", :x, :y)`.
114
+ rest = rest[1..] if rest.first.is_a?(Type::Constant) && rest.first.value.is_a?(String)
115
+
116
+ members = []
117
+ rest.each do |arg|
118
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(Symbol)
119
+
120
+ members << arg.value
121
+ end
122
+ return nil if members.empty?
123
+ return nil unless members.uniq.size == members.size
124
+
125
+ { members: members, keyword_init: keyword_init }
126
+ end
127
+
128
+ # A trailing hash is the `Struct.new` options hash only when it is a
129
+ # closed shape whose sole key is `:keyword_init`. Any other key means
130
+ # the call is not a recognisable member-list definition -> defer.
131
+ def struct_options_hash?(shape)
132
+ shape.closed? && shape.optional_keys.empty? &&
133
+ shape.pairs.keys.all? { |key| key == :keyword_init }
134
+ end
135
+
136
+ # --- 2. Point.new(...) / Point[...] / Point.members -------------
137
+
138
+ def dispatch_struct_class(struct_class, context)
139
+ case context.method_name
140
+ when :new, :[]
141
+ materialize_instance(struct_class.members, struct_class.keyword_init,
142
+ struct_class.class_name, context)
143
+ when :members
144
+ Type::Combinator.tuple_of(*struct_class.members.map { |name| Type::Combinator.constant_of(name) })
145
+ end
146
+ end
147
+
148
+ def materialize_instance(members, keyword_init, class_name, context)
149
+ return nil unless %i[new []].include?(context.method_name)
150
+
151
+ map = member_map_for_new(members, keyword_init, context)
152
+ return degraded_instance if map.nil?
153
+
154
+ Type::Combinator.struct_instance_of(members: map, class_name: class_name)
155
+ end
156
+
157
+ # Builds the member -> type map honouring the class's `keyword_init`
158
+ # flag: a `keyword_init: true` struct accepts only the keyword form,
159
+ # a positional struct only the positional form. The mismatched form
160
+ # is a different runtime shape, so it degrades rather than fold.
161
+ def member_map_for_new(members, keyword_init, context)
162
+ if keyword_new?(context)
163
+ keyword_init ? keyword_member_map(members, context.args) : nil
164
+ else
165
+ keyword_init ? nil : positional_member_map(members, context.args)
166
+ end
167
+ end
168
+
169
+ # `Point.new(x: 1)` arrives as a single trailing `HashShape` whose
170
+ # call node is a `KeywordHashNode`; distinguishing it from a
171
+ # positional hash needs the call node (both type to a `HashShape`).
172
+ def keyword_new?(context)
173
+ node = context.call_node
174
+ return false if node.nil?
175
+
176
+ arguments = node.arguments&.arguments
177
+ return false if arguments.nil? || arguments.empty?
178
+
179
+ arguments.last.is_a?(Prism::KeywordHashNode)
180
+ end
181
+
182
+ # `Struct.new(:a, :b).new(v)` is legal — trailing members default to
183
+ # `nil` — so fewer positionals than members pad with `Constant[nil]`;
184
+ # more positionals than members is an ArgumentError -> degrade.
185
+ def positional_member_map(members, args)
186
+ return nil if args.size > members.size
187
+
188
+ members.each_with_index.to_h do |name, index|
189
+ [name, index < args.size ? args[index] : Type::Combinator.constant_of(nil)]
190
+ end
191
+ end
192
+
193
+ # `Struct.new(:a, :b).new(a: 1)` is legal — omitted members default
194
+ # to `nil` — so a keyword subset pads the rest; an unknown key is an
195
+ # ArgumentError -> degrade.
196
+ def keyword_member_map(members, args)
197
+ return nil unless args.size == 1
198
+
199
+ shape = args.first
200
+ return nil unless shape.is_a?(Type::HashShape) && shape.closed?
201
+ return nil unless shape.optional_keys.empty?
202
+ return nil unless shape.pairs.keys.all? { |key| members.include?(key) }
203
+
204
+ members.to_h { |name| [name, shape.pairs[name] || Type::Combinator.constant_of(nil)] }
205
+ end
206
+
207
+ # A `.new` whose arguments cannot soundly populate the member map
208
+ # degrades to `Dynamic[top]` (today's behaviour for `Struct.new(...)`
209
+ # instances), never a wrong map.
210
+ def degraded_instance
211
+ Type::Combinator.untyped
212
+ end
213
+
214
+ # --- 3. inst.x / inst.x = v / inst[...] / inst.to_h / ... -------
215
+
216
+ def fold_instance(instance, context)
217
+ method_name = context.method_name
218
+ args = context.args
219
+ members = instance.members
220
+
221
+ # Member setter `s.x = v`: returns the assigned value type. Sound
222
+ # regardless of mutation state (it models the setter's own return),
223
+ # and avoids a fall-through undefined-method on a writer the
224
+ # existence table does not register.
225
+ setter = member_setter_target(method_name, members)
226
+ return args.first if setter && args.size == 1
227
+
228
+ foldable = foldable_receiver?(context)
229
+
230
+ if members.key?(method_name) && args.empty? && !reader_overridden?(instance, method_name, context.scope)
231
+ # A stored receiver folds only when the bound local is proven
232
+ # fold-safe (ADR-48 slice 3 — never mutated / aliased / escaped);
233
+ # otherwise the member value may be stale, so it degrades to
234
+ # `Dynamic[top]` (not nil -> no undefined-method fall-through).
235
+ return foldable ? members.fetch(method_name) : Type::Combinator.untyped
236
+ end
237
+
238
+ # Projections fold only off a foldable (fresh, or proven fold-safe)
239
+ # instance; off any other stored binding they defer to Struct's RBS
240
+ # (`to_h` / `[]` / `members` / `deconstruct*` all exist on `Struct`),
241
+ # which is sound and non-regressive.
242
+ return nil unless foldable
243
+
244
+ fold_fresh_projection(instance, method_name, args)
245
+ end
246
+
247
+ def fold_fresh_projection(instance, method_name, args)
248
+ case method_name
249
+ when :[] then instance_index(instance, args)
250
+ when :to_h, :to_hash then instance_to_h(instance)
251
+ when :deconstruct then instance_deconstruct(instance)
252
+ when :deconstruct_keys then instance_deconstruct_keys(instance, args)
253
+ when :members then instance_members(instance)
254
+ when :with
255
+ instance_with(instance, args) do |members, class_name|
256
+ Type::Combinator.struct_instance_of(members: members, class_name: class_name)
257
+ end
258
+ end
259
+ end
260
+
261
+ # The member name a `:<member>=` setter targets, or nil. Comparison
262
+ # operators (`==`, `>=`, ...) end with `=` too but never strip to a
263
+ # member symbol, so they fall through to the normal dispatch path.
264
+ def member_setter_target(method_name, members)
265
+ name = method_name.to_s
266
+ return nil unless name.end_with?("=")
267
+
268
+ base = name[0..-2].to_sym
269
+ members.key?(base) ? base : nil
270
+ end
271
+
272
+ # A member read is foldable when the receiver is either FRESH (the
273
+ # transient result of a `.new(...)`/`.with(...)` chain, which cannot
274
+ # have been mutated between materialisation and this read) or a
275
+ # FOLD-SAFE stored local (ADR-48 slice 3 — `StructFoldSafety` proved
276
+ # the binding is never mutated / aliased / escaped in its scope).
277
+ def foldable_receiver?(context)
278
+ fresh_receiver?(context) || fold_safe_local_receiver?(context)
279
+ end
280
+
281
+ # A fresh receiver is the transient result of a chained call
282
+ # (`Point.new(1, 2).x`, `inst.with(x: 9).y`).
283
+ def fresh_receiver?(context)
284
+ node = context.call_node
285
+ return false if node.nil?
286
+
287
+ node.receiver.is_a?(Prism::CallNode)
288
+ end
289
+
290
+ # A fold-safe stored receiver is a local-variable read whose name the
291
+ # body's fold-safe set (on the scope) marks as never mutated.
292
+ def fold_safe_local_receiver?(context)
293
+ node = context.call_node
294
+ receiver = node&.receiver
295
+ scope = context.scope
296
+ return false unless receiver.is_a?(Prism::LocalVariableReadNode) && scope
297
+
298
+ scope.struct_fold_safe?(receiver.name)
299
+ end
300
+ end
301
+ end
302
+ end
303
+ end