rigortype 0.1.18 → 0.2.0

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 (210) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +32 -23
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
  8. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  9. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  10. data/lib/rigor/analysis/check_rules.rb +756 -132
  11. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  12. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  13. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  14. data/lib/rigor/analysis/diagnostic.rb +8 -0
  15. data/lib/rigor/analysis/fact_store.rb +5 -4
  16. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  17. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
  18. data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
  19. data/lib/rigor/analysis/runner.rb +75 -27
  20. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  21. data/lib/rigor/analysis/worker_session.rb +31 -25
  22. data/lib/rigor/bleeding_edge.rb +123 -0
  23. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  24. data/lib/rigor/cache/descriptor.rb +86 -8
  25. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  26. data/lib/rigor/cache/store.rb +5 -3
  27. data/lib/rigor/cli/annotate_command.rb +122 -16
  28. data/lib/rigor/cli/baseline_command.rb +4 -3
  29. data/lib/rigor/cli/check_command.rb +118 -16
  30. data/lib/rigor/cli/coverage_command.rb +148 -16
  31. data/lib/rigor/cli/coverage_scan.rb +57 -0
  32. data/lib/rigor/cli/explain_command.rb +2 -0
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  35. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  36. data/lib/rigor/cli/options.rb +9 -0
  37. data/lib/rigor/cli/plugins_command.rb +4 -5
  38. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  39. data/lib/rigor/cli/protection_renderer.rb +63 -0
  40. data/lib/rigor/cli/protection_report.rb +68 -0
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  42. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  43. data/lib/rigor/cli/trace_command.rb +2 -1
  44. data/lib/rigor/cli/triage_command.rb +8 -4
  45. data/lib/rigor/cli/triage_renderer.rb +15 -1
  46. data/lib/rigor/cli/type_of_command.rb +1 -1
  47. data/lib/rigor/cli/type_scan_command.rb +2 -1
  48. data/lib/rigor/cli.rb +12 -3
  49. data/lib/rigor/configuration/dependencies.rb +2 -4
  50. data/lib/rigor/configuration/severity_profile.rb +13 -1
  51. data/lib/rigor/configuration.rb +100 -6
  52. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  53. data/lib/rigor/environment/class_registry.rb +4 -3
  54. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  55. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  56. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  57. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  58. data/lib/rigor/environment/rbs_loader.rb +74 -5
  59. data/lib/rigor/environment.rb +17 -7
  60. data/lib/rigor/flow_contribution/fact.rb +1 -1
  61. data/lib/rigor/flow_contribution.rb +3 -5
  62. data/lib/rigor/inference/acceptance.rb +17 -9
  63. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  64. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  65. data/lib/rigor/inference/budget_trace.rb +29 -2
  66. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  67. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  68. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  69. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  70. data/lib/rigor/inference/expression_typer.rb +1072 -71
  71. data/lib/rigor/inference/hkt_body.rb +8 -11
  72. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  73. data/lib/rigor/inference/hkt_registry.rb +10 -11
  74. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  75. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  76. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  77. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
  78. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  79. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  80. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  81. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  82. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  83. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  84. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  85. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  86. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  87. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +237 -24
  88. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  89. data/lib/rigor/inference/method_dispatcher.rb +112 -49
  90. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  91. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  92. data/lib/rigor/inference/mutation_widening.rb +147 -11
  93. data/lib/rigor/inference/narrowing.rb +284 -53
  94. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  95. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  97. data/lib/rigor/inference/protection_scanner.rb +86 -0
  98. data/lib/rigor/inference/scope_indexer.rb +821 -76
  99. data/lib/rigor/inference/statement_evaluator.rb +1179 -102
  100. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  101. data/lib/rigor/inference/synthetic_method.rb +7 -7
  102. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  103. data/lib/rigor/language_server/completion_provider.rb +6 -12
  104. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  105. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  106. data/lib/rigor/language_server/hover_provider.rb +2 -3
  107. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  108. data/lib/rigor/language_server/server.rb +9 -17
  109. data/lib/rigor/language_server.rb +4 -5
  110. data/lib/rigor/plugin/base.rb +245 -87
  111. data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
  112. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  113. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  114. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  115. data/lib/rigor/plugin/macro.rb +6 -8
  116. data/lib/rigor/plugin/manifest.rb +49 -90
  117. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  118. data/lib/rigor/plugin/registry.rb +18 -18
  119. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  120. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  121. data/lib/rigor/protection/mutator.rb +246 -0
  122. data/lib/rigor/rbs_extended.rb +24 -36
  123. data/lib/rigor/reflection.rb +4 -7
  124. data/lib/rigor/scope/discovery_index.rb +16 -2
  125. data/lib/rigor/scope.rb +185 -16
  126. data/lib/rigor/sig_gen/generator.rb +8 -0
  127. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  128. data/lib/rigor/sig_gen/writer.rb +40 -2
  129. data/lib/rigor/source/constant_path.rb +62 -0
  130. data/lib/rigor/source.rb +1 -0
  131. data/lib/rigor/triage/catalogue.rb +4 -19
  132. data/lib/rigor/triage.rb +69 -1
  133. data/lib/rigor/type/bound_method.rb +2 -11
  134. data/lib/rigor/type/combinator.rb +45 -3
  135. data/lib/rigor/type/constant.rb +2 -11
  136. data/lib/rigor/type/data_class.rb +2 -11
  137. data/lib/rigor/type/data_instance.rb +2 -11
  138. data/lib/rigor/type/hash_shape.rb +2 -11
  139. data/lib/rigor/type/integer_range.rb +2 -11
  140. data/lib/rigor/type/intersection.rb +2 -11
  141. data/lib/rigor/type/nominal.rb +2 -11
  142. data/lib/rigor/type/plain_lattice.rb +37 -0
  143. data/lib/rigor/type/refined.rb +72 -13
  144. data/lib/rigor/type/singleton.rb +2 -11
  145. data/lib/rigor/type/struct_class.rb +75 -0
  146. data/lib/rigor/type/struct_instance.rb +93 -0
  147. data/lib/rigor/type/tuple.rb +5 -15
  148. data/lib/rigor/type.rb +2 -0
  149. data/lib/rigor/version.rb +1 -1
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  151. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  158. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
  163. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  164. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  165. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  166. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  167. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
  171. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  172. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  173. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
  174. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  175. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  176. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
  179. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
  182. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  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 +21 -27
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  189. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  190. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  191. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  195. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  196. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  197. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
  198. data/sig/rigor/analysis/fact_store.rbs +3 -0
  199. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  200. data/sig/rigor/plugin/base.rbs +5 -2
  201. data/sig/rigor/plugin/manifest.rbs +1 -2
  202. data/sig/rigor/scope.rbs +18 -1
  203. data/sig/rigor/type.rbs +37 -1
  204. data/sig/rigor.rbs +1 -1
  205. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  206. data/skills/rigor-plugin-author/SKILL.md +6 -4
  207. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  208. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  209. metadata +25 -2
  210. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -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,
@@ -98,9 +99,16 @@ module Rigor
98
99
  uniq: :tuple_uniq,
99
100
  index: :tuple_find_index,
100
101
  find_index: :tuple_find_index,
101
- rindex: :tuple_rindex
102
+ rindex: :tuple_rindex,
103
+ flatten: :tuple_flatten,
104
+ join: :tuple_join
102
105
  }.freeze
103
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
+
104
112
  HASH_SHAPE_HANDLERS = {
105
113
  size: :hash_size,
106
114
  length: :hash_size,
@@ -228,15 +236,8 @@ module Rigor
228
236
  end
229
237
 
230
238
  def dispatch_nominal_size(nominal, method_name, args)
231
- if nominal.class_name == "String" && args.size == 1
232
- string_binary = dispatch_string_binary_from_arg(method_name, args.first)
233
- return string_binary if string_binary
234
- end
235
-
236
- if nominal.class_name == "Integer" && args.size == 1
237
- integer_binary = dispatch_integer_binary_from_arg(method_name, args.first)
238
- return integer_binary if integer_binary
239
- end
239
+ projection = nominal_projection(nominal, method_name, args)
240
+ return projection if projection
240
241
 
241
242
  return nil unless args.empty?
242
243
 
@@ -246,6 +247,106 @@ module Rigor
246
247
  Type::Combinator.non_negative_int
247
248
  end
248
249
 
250
+ # Arg-/method-driven precision projections for a `Nominal`
251
+ # receiver, consulted ahead of the no-arg size tier. Each
252
+ # branch gates on the class name first so unrelated nominals
253
+ # skip the work. Returns nil when no projection applies.
254
+ def nominal_projection(nominal, method_name, args)
255
+ case nominal.class_name
256
+ when "String"
257
+ dispatch_string_binary_from_arg(method_name, args.first) if args.size == 1
258
+ when "Integer"
259
+ dispatch_integer_binary_from_arg(method_name, args.first) if args.size == 1
260
+ when "Array"
261
+ case method_name
262
+ when :flatten then array_nominal_flatten(nominal, args)
263
+ when :compact then array_nominal_compact(nominal, args)
264
+ end
265
+ end
266
+ end
267
+
268
+ # `Array[T]#compact` — `compact` removes every `nil` element,
269
+ # so the result element type is `T` with its `nil` constituent
270
+ # stripped (`Array[Node?]#compact` → `Array[Node]`). Mirrors
271
+ # the `Tuple#compact` constant fold for the generic element
272
+ # case. Declines when the receiver carries no type argument
273
+ # (the RBS `Array[untyped]` answer is already maximal) or when
274
+ # `T` has no `nil` constituent to remove (the result equals the
275
+ # receiver, so the RBS tier's answer is already precise).
276
+ def array_nominal_compact(nominal, args)
277
+ return nil unless args.empty?
278
+
279
+ element = nominal.type_args&.first
280
+ return nil if element.nil?
281
+
282
+ stripped = strip_nil_constituent(element)
283
+ return nil if stripped.equal?(element)
284
+
285
+ Type::Combinator.nominal_of("Array", type_args: [stripped])
286
+ end
287
+
288
+ # Removes the `nil` constituent from a (possibly union) type,
289
+ # returning the same object when there is nothing to remove so
290
+ # callers can detect the no-op cheaply. Kept local to the
291
+ # dispatch tier to avoid a dependency on the narrowing module.
292
+ def strip_nil_constituent(type)
293
+ case type
294
+ when Type::Constant
295
+ type.value.nil? ? Type::Combinator.bot : type
296
+ when Type::Nominal
297
+ type.class_name == "NilClass" ? Type::Combinator.bot : type
298
+ when Type::Union
299
+ kept = type.members.map { |m| strip_nil_constituent(m) }
300
+ return type if kept.zip(type.members).all? { |k, m| k.equal?(m) }
301
+
302
+ Type::Combinator.union(*kept)
303
+ else
304
+ type
305
+ end
306
+ end
307
+
308
+ # `Array[T]#flatten` (and `flatten(depth)`). When `T` is a
309
+ # nested `Array[U]` nominal, one flatten level yields the
310
+ # joined inner element type — `Array[Array[U]]#flatten` →
311
+ # `Array[U]`. When `T` is non-nested the result is `Array[T]`
312
+ # unchanged (Ruby returns a copy with the same element type).
313
+ # Multi-level nesting is handled conservatively: each level
314
+ # joins its element types, and a `depth` argument that does
315
+ # not fully resolve the nesting still produces a sound
316
+ # superset. Declines on an `Array` with no type argument
317
+ # (the RBS `Array[untyped]` answer is already as precise as
318
+ # we can be) and on a non-static depth argument.
319
+ def array_nominal_flatten(nominal, args)
320
+ element = nominal.type_args&.first
321
+ return nil if element.nil?
322
+
323
+ depth = tuple_flatten_depth(args)
324
+ return nil if depth == :decline
325
+
326
+ flattened = flatten_nominal_element(element, depth)
327
+ Type::Combinator.nominal_of("Array", type_args: [flattened])
328
+ end
329
+
330
+ # Resolves the element type of a flattened `Array[element]`.
331
+ # Each `Array[U]` nesting level contributes `U`; the per-level
332
+ # element types are unioned. `depth < 0` recurses without
333
+ # bound; `depth == 0` stops (Ruby's `flatten(0)` is a no-op
334
+ # copy and returns the element unchanged).
335
+ def flatten_nominal_element(element, depth)
336
+ return element if depth.zero?
337
+ return element unless array_nominal?(element)
338
+
339
+ inner = element.type_args.first
340
+ return element if inner.nil?
341
+
342
+ flatten_nominal_element(inner, depth - 1)
343
+ end
344
+
345
+ def array_nominal?(type)
346
+ type.is_a?(Type::Nominal) && type.class_name == "Array" && !type.type_args.nil? &&
347
+ !type.type_args.empty?
348
+ end
349
+
249
350
  # Arg-type-driven String binary projections for any String-typed
250
351
  # receiver (including Nominal, Refined, and Difference fallbacks).
251
352
  # Called before the no-arg size guard so binary operators are seen.
@@ -479,8 +580,17 @@ module Rigor
479
580
  %i[lowercase upcase] => :uppercase_string,
480
581
  %i[uppercase upcase] => :refined_self,
481
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.
482
592
  %i[numeric downcase] => :refined_self,
483
- %i[numeric upcase] => :refined_self,
593
+ %i[numeric upcase] => :base_string,
484
594
  # Digit-only strings are case-invariant; the prefix
485
595
  # letters in `0o…` / `0x…` are accepted by the
486
596
  # predicate in either case so the predicate-subset
@@ -493,19 +603,19 @@ module Rigor
493
603
  %i[hex_int downcase] => :refined_self,
494
604
  %i[hex_int upcase] => :refined_self,
495
605
  # v0.1.1 Track 1 slice 2 — `to_i` / `to_int` on a
496
- # known digit-only string. `decimal-int-string`
497
- # (`/\A\d+\z/`) and `numeric-string` (Rigor's
498
- # numeric-string predicate, ASCII digits) are
499
- # predicates over digit-only strings, so the parse
500
- # is total over the carrier domain and the result
501
- # is always `>= 0`. `non-negative-int` is the
502
- # tightest carrier that captures both the lower
503
- # bound and the integer-ness without inventing a
504
- # narrower carrier.
505
- %i[decimal_int to_i] => :non_negative_int,
506
- %i[decimal_int to_int] => :non_negative_int,
507
- %i[numeric to_i] => :non_negative_int,
508
- %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
509
619
  })
510
620
  private_constant :REFINED_STRING_PROJECTIONS
511
621
 
@@ -530,6 +640,8 @@ module Rigor
530
640
  when :uppercase_string then Type::Combinator.uppercase_string
531
641
  when :lowercase_string then Type::Combinator.lowercase_string
532
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
533
645
  end
534
646
  end
535
647
 
@@ -697,6 +809,37 @@ module Rigor
697
809
  Type::Combinator.constant_of(values.sum)
698
810
  end
699
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
+
700
843
  # `tuple.min` / `tuple.max` — fold when every element is
701
844
  # a `Constant` whose values share a Ruby-comparable
702
845
  # domain. Empty tuples fold to `Constant[nil]`.
@@ -721,6 +864,32 @@ module Rigor
721
864
  nil
722
865
  end
723
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
+
724
893
  # `tuple.sort` — every element must be a `Constant` and
725
894
  # the values must Ruby-compare. The result is a Tuple
726
895
  # with the same elements in sorted order. Comparison
@@ -875,6 +1044,50 @@ module Rigor
875
1044
  constant_index(tuple, args) { |elements, value| elements.index { |e| e.value == value } }
876
1045
  end
877
1046
 
1047
+ # `tuple.flatten` / `tuple.flatten(depth)` — recursively
1048
+ # flattens nested Tuple elements into a single Tuple. With
1049
+ # no argument the flatten is unbounded (matching Ruby's
1050
+ # `Array#flatten`); a `Constant[Integer]` depth bounds it.
1051
+ # Non-Tuple elements (scalars, `Array[T]` nominals, …) pass
1052
+ # through unchanged at their level. A non-static depth
1053
+ # argument (or a non-Integer one) declines so RBS answers.
1054
+ def tuple_flatten(tuple, _method_name, args)
1055
+ depth = tuple_flatten_depth(args)
1056
+ return nil if depth == :decline
1057
+
1058
+ Type::Combinator.tuple_of(*flatten_elements(tuple.elements, depth))
1059
+ end
1060
+
1061
+ # Returns the requested flatten depth: `-1` for the no-arg
1062
+ # (unbounded) form, the Integer for a `Constant[Integer]`
1063
+ # argument, or `:decline` for any non-static / wrong-arity
1064
+ # argument shape.
1065
+ def tuple_flatten_depth(args)
1066
+ return -1 if args.empty?
1067
+ return :decline unless args.size == 1
1068
+
1069
+ arg = args.first
1070
+ return arg.value if arg.is_a?(Type::Constant) && arg.value.is_a?(Integer)
1071
+
1072
+ :decline
1073
+ end
1074
+
1075
+ # Flattens a list of element types to `depth` levels.
1076
+ # `depth < 0` means unbounded. A Tuple element is spliced
1077
+ # in (recursing with `depth - 1`); everything else passes
1078
+ # through at this level.
1079
+ def flatten_elements(elements, depth)
1080
+ return elements if depth.zero?
1081
+
1082
+ elements.flat_map do |element|
1083
+ if element.is_a?(Type::Tuple)
1084
+ flatten_elements(element.elements, depth - 1)
1085
+ else
1086
+ [element]
1087
+ end
1088
+ end
1089
+ end
1090
+
878
1091
  # `rindex(obj)` → the LAST matching index, same decidability gate.
879
1092
  def tuple_rindex(tuple, _method_name, args)
880
1093
  constant_index(tuple, args) { |elements, value| elements.rindex { |e| e.value == value } }
@@ -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