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
@@ -12,9 +12,12 @@ require_relative "method_dispatcher/constant_folding"
12
12
  require_relative "method_dispatcher/literal_string_folding"
13
13
  require_relative "method_dispatcher/shape_dispatch"
14
14
  require_relative "method_dispatcher/data_folding"
15
+ require_relative "method_dispatcher/struct_folding"
15
16
  require_relative "method_dispatcher/rbs_dispatch"
16
17
  require_relative "method_dispatcher/iterator_dispatch"
18
+ require_relative "method_dispatcher/reduce_folding"
17
19
  require_relative "method_dispatcher/block_folding"
20
+ require_relative "method_dispatcher/array_to_h_folding"
18
21
  require_relative "method_dispatcher/file_folding"
19
22
  require_relative "method_dispatcher/shellwords_folding"
20
23
  require_relative "method_dispatcher/math_folding"
@@ -36,28 +39,15 @@ module Rigor
36
39
  # callers (today only `ExpressionTyper`) own the fail-soft fallback
37
40
  # and decide whether to record a `FallbackTracer` event.
38
41
  #
39
- # Tiers (in order):
42
+ # Tier order is documented inline in `resolve`; the precise-tier
43
+ # group is built from `PRECISE_TIERS_HEAD`, `STDLIB_SINGLETON_FOLDERS`,
44
+ # and `PRECISE_TIERS_TAIL`. `ShapeDispatch` runs above {RbsDispatch}
45
+ # so a precise per-position/per-key answer wins over the projected
46
+ # `Array#[]`/`Hash#fetch` RBS answer.
40
47
  #
41
- # 1. {ConstantFolding}: executes the Ruby operation directly when
42
- # the receiver and argument are `Constant` carriers and the
43
- # method is on the curated whitelist. Slice 2.
44
- # 2. {ShapeDispatch}: returns the precise element/value type for a
45
- # curated catalogue of `Tuple`/`HashShape` element-access
46
- # methods (`first`, `last`, `[]` with a static integer/key,
47
- # `fetch`, `dig`, `size`/`length`/`count`). Slice 5 phase 2.
48
- # 3. {RbsDispatch}: looks up the receiver's class in the RBS
49
- # environment carried by the scope and translates the method's
50
- # return type into a Rigor::Type. Slice 4.
51
- #
52
- # `ShapeDispatch` deliberately runs *above* {RbsDispatch} so the
53
- # precise per-position/per-key answer wins over the projected
54
- # `Array#[]`/`Hash#fetch` answer; it falls through (`nil`) when
55
- # the call cannot be proved against the static shape, in which
56
- # case the projection answer from {RbsDispatch} applies.
57
- #
58
- # The dispatcher's public signature reserves space for `block_type:`
59
- # and ADR-2 plugin extensions (later slices), so call sites added
60
- # now do not have to be rewritten when those tiers arrive.
48
+ # The `block_type:` and plugin contribution (`dynamic_return`) tiers
49
+ # landed in Slice 6 phase C and v0.1.1 Track 2 respectively; all
50
+ # call sites pass through `dispatch`/`resolve` unchanged.
61
51
  module MethodDispatcher # rubocop:disable Metrics/ModuleLength
62
52
  module_function
63
53
 
@@ -274,7 +264,14 @@ module Rigor
274
264
  class_name, kind = discovered_method_lookup(receiver_type)
275
265
  return nil if class_name.nil?
276
266
  return nil unless scope.discovered_method?(class_name, method_name, kind)
267
+ # Decline when a re-typable body is recorded for the method, so the
268
+ # downstream `ExpressionTyper` inference tier can fold a precise
269
+ # return instead of collapsing to `Dynamic[top]` here — instance
270
+ # bodies via `user_def_for`, singleton bodies (`def self.x` /
271
+ # `module_function`) via `singleton_def_for` (module-singleton
272
+ # call resolution, ADR-57 follow-up).
277
273
  return nil if kind == :instance && scope.user_def_for(class_name, method_name)
274
+ return nil if kind == :singleton && scope.singleton_def_for(class_name, method_name)
278
275
 
279
276
  Type::Combinator.untyped
280
277
  end
@@ -317,19 +314,6 @@ module Rigor
317
314
  Type::Combinator.untyped
318
315
  end
319
316
 
320
- # ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
321
- # slice 7; ADR-52 WD3 — consults each loaded plugin's gated
322
- # `dynamic_return` rules, wraps the contributed types as
323
- # `FlowContribution` bundles, merges them through
324
- # `FlowContribution::Merger`, and returns the merged
325
- # `return_type` slot (or nil when no plugin contributed a
326
- # return type).
327
- #
328
- # Plugins whose hook raises have their contribution
329
- # silently dropped for this call so the dispatch chain
330
- # keeps moving — the run-level diagnostic envelope (per
331
- # ADR-2 § "Plugin Trust and I/O Policy") is owned by
332
- # `Analysis::Runner#plugin_emitted_diagnostics`.
333
317
  # ADR-20 slice 3 — looks up the receiver / method pair
334
318
  # in {Rigor::Builtins::HktBuiltins::METHOD_RETURN_OVERRIDES}
335
319
  # and returns the reduced HKT type. Only fires when the
@@ -404,6 +388,19 @@ module Rigor
404
388
  end
405
389
  end
406
390
 
391
+ # ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
392
+ # slice 7; ADR-52 WD3 — consults each loaded plugin's gated
393
+ # `dynamic_return` rules, wraps the contributed types as
394
+ # `FlowContribution` bundles, merges them through
395
+ # `FlowContribution::Merger`, and returns the merged
396
+ # `return_type` slot (or nil when no plugin contributed a
397
+ # return type).
398
+ #
399
+ # Plugins whose hook raises have their contribution
400
+ # silently dropped for this call so the dispatch chain
401
+ # keeps moving — the run-level diagnostic envelope (per
402
+ # ADR-2 § "Plugin Trust and I/O Policy") is owned by
403
+ # `Analysis::Runner#plugin_emitted_diagnostics`.
407
404
  def try_plugin_contribution(call_node, scope, receiver_type)
408
405
  return nil if call_node.nil? || scope.nil?
409
406
 
@@ -416,16 +413,6 @@ module Rigor
416
413
  FlowContribution::Merger.merge(contributions).return_type
417
414
  end
418
415
 
419
- # ADR-10 slice 2b-ii. Consults the per-run
420
- # `Analysis::DependencySourceInference::Index` carried by
421
- # the environment for `(class_name, method_name)`
422
- # observations harvested from opt-in gems' `roots:`. On a
423
- # hit, returns `Combinator.untyped` so the call site
424
- # carries the `Dynamic[top]` provenance (per ADR-10's
425
- # "Inference contract": gem-source-inferred shapes never
426
- # publish as ground-truth `T`). Returns `nil` when the
427
- # environment carries no index, the index has no entry, or
428
- # the receiver has no nominal class to look up.
429
416
  # ADR-16 synthetic-method tier. Slice 2b shipped the floor —
430
417
  # a match short-circuits at the right precedence (above
431
418
  # dep-source / discovered / user-class-fallback; below RBS)
@@ -547,6 +534,16 @@ module Rigor
547
534
  Type::Combinator.dynamic(entry.return_type)
548
535
  end
549
536
 
537
+ # ADR-10 slice 2b-ii. Consults the per-run
538
+ # `Analysis::DependencySourceInference::Index` carried by
539
+ # the environment for `(class_name, method_name)`
540
+ # observations harvested from opt-in gems' `roots:`. On a
541
+ # hit, returns `Combinator.untyped` so the call site
542
+ # carries the `Dynamic[top]` provenance (per ADR-10's
543
+ # "Inference contract": gem-source-inferred shapes never
544
+ # publish as ground-truth `T`). Returns `nil` when the
545
+ # environment carries no index, the index has no entry, or
546
+ # the receiver has no nominal class to look up.
550
547
  def try_dependency_source(receiver_type, method_name, environment)
551
548
  index = environment&.dependency_source_index
552
549
  return nil if index.nil? || index.empty?
@@ -750,10 +747,6 @@ module Rigor
750
747
  # below this chain and is invoked by the outer `dispatch`
751
748
  # method.
752
749
  #
753
- # `BlockFolding` runs last among the precision tiers because
754
- # its rules apply only to block-taking calls, so the cheaper
755
- # arity-based fold tiers above it filter out the common
756
- # cases first. When `block_type` is nil the tier is a no-op.
757
750
  # The precise-tier folders, consulted in order via the uniform
758
751
  # `_DispatchTier` interface (`try_dispatch(CallContext) -> Type?`).
759
752
  # Order is significant: ConstantFolding's exact-value folds win
@@ -792,7 +785,7 @@ module Rigor
792
785
  private_constant :STDLIB_SINGLETON_FOLDERS
793
786
 
794
787
  PRECISE_TIERS_TAIL = Ractor.make_shareable([
795
- KernelDispatch, MethodFolding, BlockFolding
788
+ KernelDispatch, MethodFolding, ReduceFolding, ArrayToHFolding, BlockFolding
796
789
  ].freeze)
797
790
  private_constant :PRECISE_TIERS_TAIL
798
791
 
@@ -806,6 +799,14 @@ module Rigor
806
799
  data_result = DataFolding.try_dispatch(context)
807
800
  return data_result if data_result
808
801
 
802
+ # ADR-48 Struct follow-up — runs in the same band and for the same
803
+ # reason as DataFolding: `meta_new` would otherwise intercept every
804
+ # `Singleton[*].new` (the old `struct_new_lift` produced a bare
805
+ # `Singleton[Struct]`), masking a Struct class's precise instance.
806
+ # Only fires on Struct receivers, so it never shadows meta's lifts.
807
+ struct_result = StructFolding.try_dispatch(context)
808
+ return struct_result if struct_result
809
+
809
810
  meta_result = try_meta_introspection(context.receiver, context.method_name, context.args)
810
811
  return meta_result if meta_result
811
812
 
@@ -952,18 +953,53 @@ module Rigor
952
953
  set_lift = set_new_lift(receiver_type.class_name, arg_types)
953
954
  return set_lift if set_lift
954
955
 
956
+ hash_lift = hash_new_lift(receiver_type.class_name, arg_types)
957
+ return hash_lift if hash_lift
958
+
955
959
  regexp_lift = regexp_new_lift(receiver_type.class_name, arg_types)
956
960
  return regexp_lift if regexp_lift
957
961
 
958
962
  date_lift = date_new_lift(receiver_type.class_name, arg_types)
959
963
  return date_lift if date_lift
960
964
 
965
+ struct_new_lift = struct_new_lift(receiver_type.class_name, arg_types)
966
+ return struct_new_lift if struct_new_lift
967
+
961
968
  class_new_lift = class_new_lift(receiver_type.class_name, arg_types)
962
969
  return class_new_lift if class_new_lift
963
970
 
964
971
  Type::Combinator.nominal_of(receiver_type.class_name)
965
972
  end
966
973
 
974
+ # `Struct.new(:a, :b)` synthesises an anonymous Struct *subclass*
975
+ # (a class object), not a Struct *instance* — so the chained
976
+ # idiom `Struct.new(:a, :b).new(1, 2)` must resolve `.new` again
977
+ # on a class-like carrier. The constant-bound form
978
+ # (`S = Struct.new(:a); S.new(1)`) already records `Singleton[S]`
979
+ # via `ScopeIndexer#record_meta_new_constant?`; this lift gives
980
+ # the *chained* (anonymous) position the same class-like carrier
981
+ # so the trailing `.new` dispatches instead of firing a spurious
982
+ # `undefined method 'new' for Struct`.
983
+ #
984
+ # The disambiguation mirrors `ScopeIndexer#struct_new_call?`: a
985
+ # call whose positionals are all `Constant<Symbol>` literals is a
986
+ # member-list class definition → `Singleton[Struct]`. The
987
+ # following `AnonStruct.new(1, 2)` carries non-symbol args, so it
988
+ # falls through this gate to `Nominal[Struct]` (a fresh instance)
989
+ # via the `meta_new` tail. ADR-48 deferred full Struct *value*
990
+ # folding (member-reader precision) on mutability grounds; this is
991
+ # the narrower `.new`-dispatch-only fix and contributes no member
992
+ # layout, so `instance.a` stays at its RBS/Dynamic type.
993
+ def struct_new_lift(class_name, arg_types)
994
+ return nil unless class_name == "Struct"
995
+
996
+ positional = arg_types.grep_v(Type::HashShape)
997
+ return nil if positional.empty?
998
+ return nil unless positional.all? { |t| t.is_a?(Type::Constant) && t.value.is_a?(Symbol) }
999
+
1000
+ Type::Combinator.singleton_of("Struct")
1001
+ end
1002
+
967
1003
  # `Class.new` and `Class.new(Parent)` create a brand-new
968
1004
  # anonymous class. Statically that class is representable as
969
1005
  # the parent's singleton type — its singleton-method surface
@@ -1050,6 +1086,33 @@ module Rigor
1050
1086
  type
1051
1087
  end
1052
1088
 
1089
+ # `Hash.new(default)` — lifts the default value's type into the
1090
+ # Hash's value parameter so a subsequent `h[k]` read surfaces the
1091
+ # default type rather than `Dynamic[top]`. The common counter
1092
+ # idiom `h = Hash.new(0); h[k] += 1` then types the read as
1093
+ # `Integer`. The key parameter is left `untyped` (the default
1094
+ # carrier imposes no key constraint), so reads of any key resolve
1095
+ # through the value parameter. A value-pinned `Constant` default
1096
+ # (`0`) is widened to its nominal (`Integer`): the hash's values
1097
+ # mutate over its lifetime, so pinning the parameter to the
1098
+ # literal would be unsound for the aggregate.
1099
+ #
1100
+ # Only the single-argument default form folds. The zero-arg
1101
+ # (`Hash.new`) and the block form (`Hash.new { |h, k| … }`) keep
1102
+ # the bare `Nominal[Hash]` answer — the block's value type is not
1103
+ # available at this `:new` dispatch site, and conservatively
1104
+ # leaving the read as today's behaviour is precision-additive.
1105
+ def hash_new_lift(class_name, arg_types)
1106
+ return nil unless class_name == "Hash"
1107
+ return nil unless arg_types.size == 1
1108
+
1109
+ default = arg_types.first
1110
+ return nil if default.nil?
1111
+
1112
+ value = Type::Combinator.widen_value_pinned(default)
1113
+ Type::Combinator.nominal_of("Hash", type_args: [Type::Combinator.untyped, value])
1114
+ end
1115
+
1053
1116
  # `Range.new(b, e)` / `Range.new(b, e, excl)` — folds to
1054
1117
  # `Constant[Range]` when both endpoints are `Constant[Integer]`
1055
1118
  # or both are `Constant[String]`, and the optional third argument
@@ -32,6 +32,35 @@ module Rigor
32
32
  # `#singleton_method`.
33
33
  #
34
34
  # See docs/internal-spec/inference-engine.md for the binding contract.
35
+ # Leaf-name extraction for a destructured positional parameter
36
+ # (`Prism::MultiTargetNode`). Stateless; lifted out of
37
+ # {MethodParameterBinder} so the binder's class length stays in
38
+ # budget.
39
+ module Destructure
40
+ module_function
41
+
42
+ # Collect every leaf local name a `MultiTargetNode` binds,
43
+ # recursing through nested destructures (`((a, b), c)`) and the
44
+ # splat slot (`(a, *rest)`). Targets without a `#name` (an
45
+ # index/call write target, vanishingly rare in a parameter
46
+ # position) are skipped — there is no local to bind.
47
+ def target_names(multi_target)
48
+ entries = multi_target.lefts + [multi_target.rest, *multi_target.rights].compact
49
+ entries.flat_map { |entry| names_for_entry(entry) }
50
+ end
51
+
52
+ def names_for_entry(entry)
53
+ # A splat sub-target (`*rest` inside the destructure) wraps its
54
+ # real target in a `SplatNode#expression`; unwrap it.
55
+ entry = entry.expression if entry.is_a?(Prism::SplatNode) && entry.expression
56
+ return [] if entry.nil?
57
+ return target_names(entry) if entry.is_a?(Prism::MultiTargetNode)
58
+ return [entry.name] if entry.respond_to?(:name) && entry.name
59
+
60
+ []
61
+ end
62
+ end
63
+
35
64
  class MethodParameterBinder
36
65
  # @param environment [Rigor::Environment]
37
66
  # @param class_path [String, nil] the qualified name of the class
@@ -112,14 +141,39 @@ module Rigor
112
141
 
113
142
  def positional_slots(params_node)
114
143
  slots = []
115
- params_node.requireds.each_with_index { |p, i| slots << ParamSlot.new(:required_positional, p.name, i) }
144
+ params_node.requireds.each_with_index do |p, i|
145
+ append_positional_slot(slots, :required_positional, p, i)
146
+ end
116
147
  params_node.optionals.each_with_index { |p, i| slots << ParamSlot.new(:optional_positional, p.name, i) }
117
148
  rest = params_node.rest
118
149
  slots << ParamSlot.new(:rest_positional, rest.name, nil) if rest.respond_to?(:name) && rest&.name
119
- params_node.posts.each_with_index { |p, i| slots << ParamSlot.new(:trailing_positional, p.name, i) }
150
+ params_node.posts.each_with_index do |p, i|
151
+ append_positional_slot(slots, :trailing_positional, p, i)
152
+ end
120
153
  slots
121
154
  end
122
155
 
156
+ # A destructured positional parameter — `def f((a, b))` — is a
157
+ # `Prism::MultiTargetNode` in the `requireds`/`posts` list, not a
158
+ # `RequiredParameterNode`, so it has no `#name`. Bind each leaf
159
+ # sub-target local to `Dynamic[Top]` (a `:destructured_positional`
160
+ # slot with no RBS index) instead of crashing on a blind `.name`.
161
+ # Binding the names at all is what matters: it keeps the
162
+ # destructured locals present in the entry scope so the body's
163
+ # reads of them don't fall through to undefined-local noise. The
164
+ # element types are not cheaply available from the parameter list
165
+ # alone (no RBS function param maps onto a destructured slot), so
166
+ # `Dynamic[Top]` is the sound default.
167
+ def append_positional_slot(slots, kind, param, index)
168
+ if param.is_a?(Prism::MultiTargetNode)
169
+ Destructure.target_names(param).each do |name|
170
+ slots << ParamSlot.new(:destructured_positional, name, nil)
171
+ end
172
+ else
173
+ slots << ParamSlot.new(kind, param.name, index)
174
+ end
175
+ end
176
+
123
177
  def keyword_slots(params_node)
124
178
  params_node.keywords.filter_map do |kw|
125
179
  case kw
@@ -100,19 +100,62 @@ module Rigor
100
100
 
101
101
  def decompose_tuple(tuple, front_count, back_count, rest_present:)
102
102
  elements = tuple.elements
103
- fronts = Array.new(front_count) { |i| elements[i] || Type::Combinator.constant_of(nil) }
103
+ fronts = Array.new(front_count) { |i| slot_type(elements, i) }
104
104
  if rest_present
105
105
  middle_end = [elements.size - back_count, front_count].max
106
106
  middle = elements[front_count...middle_end] || []
107
107
  rest_type = Type::Combinator.tuple_of(*middle)
108
- backs = Array.new(back_count) { |i| elements[middle_end + i] || Type::Combinator.constant_of(nil) }
108
+ backs = Array.new(back_count) { |i| slot_type(elements, middle_end + i) }
109
109
  else
110
110
  rest_type = nil
111
- backs = Array.new(back_count) { |i| elements[front_count + i] || Type::Combinator.constant_of(nil) }
111
+ backs = Array.new(back_count) { |i| slot_type(elements, front_count + i) }
112
112
  end
113
113
  [fronts, rest_type, backs]
114
114
  end
115
115
 
116
+ # The per-slot type for index `i` of a tuple decomposition, FP-safely
117
+ # softened: a missing slot is `nil` (the runtime value of an
118
+ # over-destructured positional), and a PRESENT but nil-bearing slot
119
+ # (`X | nil`) is softened to its non-`nil` part — for a heterogeneous
120
+ # `Tuple` whose optional slot was made optional by flow.
121
+ #
122
+ # Rationale (ADR-57 slice 3 work-item 2): a destructure of a tuple
123
+ # element that flow typed as optional is almost always guarded by a
124
+ # CORRELATED invariant the flow engine cannot prove. The canonical case
125
+ # is haml's `parse_tag`, which returns `[..., last_line || @line.index
126
+ # + 1]` — a 9-tuple whose `last_line` slot widens to `Dynamic[top]?`
127
+ # through a loop-nested destructure; at the call site `..., last_line =
128
+ # parse_tag(text); raise(..., last_line - 1) if parse && value.empty?`
129
+ # the `last_line` is nil ONLY when an earlier element is too, and the
130
+ # guard short-circuits — but that correlation lives across slots, so
131
+ # per-slot flow sees `last_line` as nil-able and `last_line - 1` fires a
132
+ # spurious `possible nil receiver`. Manufacturing a `T?` for every
133
+ # destructured slot frightens working code; FP discipline (the program
134
+ # works) outranks the worst-case per-slot reading, so we drop the `nil`
135
+ # from a destructured slot and keep the non-`nil` constituent (a bare
136
+ # `nil` slot stays `nil` — there is nothing to soften). A pure non-
137
+ # optional element keeps its precise type unchanged.
138
+ def slot_type(elements, index)
139
+ element = elements[index]
140
+ return Type::Combinator.constant_of(nil) if element.nil?
141
+
142
+ soften_optional_slot(element)
143
+ end
144
+
145
+ def soften_optional_slot(element)
146
+ return element unless element.is_a?(Type::Union)
147
+ return element unless element.members.any? { |m| nil_literal?(m) }
148
+
149
+ non_nil = element.members.reject { |m| nil_literal?(m) }
150
+ return element if non_nil.empty? # a bare `nil` slot: nothing to soften
151
+
152
+ Type::Combinator.union(*non_nil)
153
+ end
154
+
155
+ def nil_literal?(member)
156
+ member.is_a?(Type::Constant) && member.value.nil?
157
+ end
158
+
116
159
  def decompose_default(front_count, back_count, rest_present:)
117
160
  [
118
161
  Array.new(front_count) { Type::Combinator.untyped },
@@ -67,18 +67,12 @@ module Rigor
67
67
  # ...; @warning_issued = true`). Requires tracking the
68
68
  # first-write position; flow-sensitive but orthogonal.
69
69
  # - **Local-variable mutation inside a block body** (e.g.
70
- # `arr = []; xs.each { |x| arr << x }`). Block bodies
71
- # create a child scope; the existing closure-escape model
72
- # only widens outer locals when the block ESCAPES the
73
- # call. An in-place mutator inside a non-escaping block on
74
- # an outer LOCAL does not yet flow back. **Ivar mutations
75
- # inside a block ARE handled** (ivars live in the
76
- # method-body scope, not the block-local scope) — the
77
- # widening fires from inside the block and the new ivar
78
- # binding is visible to the outer scope.
70
+ # `arr = []; xs.each { |x| arr << x }`) landed as
71
+ # ADR-56 slice A (`widen_after_block`). **Ivar mutations
72
+ # inside a block ARE also handled** (ivars live in the
73
+ # method-body scope, not the block-local scope).
79
74
  #
80
- # Those four are documented as "G2 remaining" in
81
- # `docs/CURRENT_WORK.md` and are intentionally deferred.
75
+ # The remaining three items above are demand-gated; see ADR-56.
82
76
  module MutationWidening
83
77
  # Array mutators that change either the size or the element
84
78
  # set of a literal-shape carrier (Tuple). Receiver-mutating
@@ -280,6 +274,148 @@ module Rigor
280
274
  carriers = kinds.map { |name| Type::Combinator.nominal_of(name) }
281
275
  carriers.size == 1 ? carriers.first : Type::Combinator.union(*carriers)
282
276
  end
277
+
278
+ # ----------------------------------------------------------------
279
+ # ADR-56 slice C — receiver-content element-type JOIN.
280
+ #
281
+ # `widen_after_block` above forgets a literal-shape carrier's arity
282
+ # when a captured local is content-mutated inside a block, but it
283
+ # keeps only the SEED's element types — an unsound under-
284
+ # approximation for a non-empty seed (`out = [0]; arr.each { |x|
285
+ # out << x }` types `Array[0]` while the runtime array is
286
+ # `[0, 1, 2, 3]`). Slice C joins the appended/stored element (and
287
+ # key/value) types INTO the continuation collection's parameter, so
288
+ # the result is `Array[0 | Integer]` rather than `Array[0]`.
289
+ #
290
+ # Array content-mutators that append/store ELEMENTS. The appended
291
+ # element type is the call's argument type(s); `[]=`'s value is its
292
+ # LAST argument (the keys precede it). Subset of `ARRAY_MUTATORS`:
293
+ # only the element-INTRODUCING methods (removers / reorderers add no
294
+ # new element evidence and are already covered by the arity-forget).
295
+ ARRAY_CONTENT_ADDERS = %i[
296
+ << push append prepend unshift concat insert []= fill replace
297
+ ].to_set.freeze
298
+
299
+ # Hash content-mutators that store a key→value pair. For `[]=` /
300
+ # `store` the key is the first argument and the value the last.
301
+ HASH_CONTENT_ADDERS = %i[[]= store].to_set.freeze
302
+
303
+ # String content-mutators that append to the buffer. String carries
304
+ # no element parameter, so these contribute nothing to a join — they
305
+ # are listed so the orchestrator recognises them as content mutators
306
+ # (the binding already widens to `String` via normal typing); the
307
+ # join helpers below short-circuit on a non-collection pre-state.
308
+ STRING_CONTENT_ADDERS = %i[<< concat prepend insert replace].to_set.freeze
309
+
310
+ # Every method name that mutates a captured local's CONTENT — the
311
+ # union the orchestrator scans the block body for.
312
+ CONTENT_ADDERS = (ARRAY_CONTENT_ADDERS | HASH_CONTENT_ADDERS | STRING_CONTENT_ADDERS).freeze
313
+
314
+ # The element types a single content-mutator call introduces into an
315
+ # Array, given the per-argument types (already typed in the block
316
+ # body scope). `concat`/`replace` take collection arguments, so their
317
+ # element evidence is the arguments' OWN element types unioned; the
318
+ # rest append the argument values directly. Returns `[]` when no
319
+ # element evidence (e.g. a `<<` with no resolvable arg).
320
+ def array_added_elements(method_name, arg_types)
321
+ return [] if arg_types.empty?
322
+
323
+ case method_name
324
+ when :concat, :replace
325
+ arg_types.flat_map { |t| collection_element_types(t) }
326
+ when :insert
327
+ # `insert(index, *objs)` — first arg is the position.
328
+ arg_types.drop(1)
329
+ when :[]=
330
+ # `arr[i] = v` / `arr[i, n] = v` — value is the last argument.
331
+ [arg_types.last]
332
+ when :fill
333
+ # `fill(value)` — only the no-block single-value form adds a
334
+ # concrete element; block / range forms are conservatively
335
+ # ignored (the arity-forget already widened the binding).
336
+ arg_types.size == 1 ? arg_types : []
337
+ else # << push append prepend unshift
338
+ arg_types
339
+ end
340
+ end
341
+
342
+ # Builds the continuation Array type from the pre-state binding and
343
+ # the appended element types. The floor is `Array[Dynamic[top]]`
344
+ # (the sound empty-seed behaviour) when there is no element evidence
345
+ # at all.
346
+ def join_array_content(pre_state, added_elements)
347
+ seed_elements = collection_element_types(pre_state)
348
+ added = added_elements.compact
349
+ # The empty-seed floor element is `Dynamic[top]` (no element
350
+ # evidence). When real appended evidence exists that floor carries
351
+ # nothing, so drop it — an empty accumulator built by `out << x*2`
352
+ # reads `Array[Integer]`, not `Array[Integer | Dynamic[top]]`.
353
+ seed_elements = drop_dynamic(seed_elements) unless added.empty?
354
+ elements = seed_elements + added
355
+ return Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped]) if elements.empty?
356
+
357
+ Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.union(*elements)])
358
+ end
359
+
360
+ # Builds the continuation Hash type from the pre-state binding and a
361
+ # list of `[key_type, value_type]` pairs stored by `[]=` / `store`.
362
+ def join_hash_content(pre_state, added_pairs)
363
+ seed_keys, seed_values = hash_shape_key_values(pre_state)
364
+ added_keys = added_pairs.map(&:first).compact
365
+ added_values = added_pairs.map(&:last).compact
366
+ seed_keys = drop_dynamic(seed_keys) unless added_keys.empty?
367
+ seed_values = drop_dynamic(seed_values) unless added_values.empty?
368
+ keys = seed_keys + added_keys
369
+ values = seed_values + added_values
370
+ key_t = keys.empty? ? Type::Combinator.untyped : Type::Combinator.union(*keys)
371
+ value_t = values.empty? ? Type::Combinator.untyped : Type::Combinator.union(*values)
372
+ Type::Combinator.nominal_of("Hash", type_args: [key_t, value_t])
373
+ end
374
+
375
+ # Drops `Dynamic` (incl. `untyped`) constituents from a type list.
376
+ def drop_dynamic(types)
377
+ types.grep_v(Type::Dynamic)
378
+ end
379
+
380
+ # Element types carried by a collection binding, regardless of which
381
+ # carrier holds them: a `Tuple` lists them, a `Nominal[Array, [E]]`
382
+ # has one element param, a bare `Array` / anything else yields none.
383
+ def collection_element_types(type)
384
+ case type
385
+ when Type::Tuple
386
+ type.elements
387
+ when Type::Nominal
388
+ type.class_name == "Array" ? type.type_args : []
389
+ when Type::Union
390
+ # A loop's single-pass join can union the widened collection with
391
+ # its un-widened literal seed (`Array[0] | [0]`); pull element
392
+ # evidence from every Array-ish member.
393
+ type.members.flat_map { |m| collection_element_types(m) }
394
+ else
395
+ []
396
+ end
397
+ end
398
+
399
+ # `[keys, values]` evidence from a Hash-ish pre-state binding —
400
+ # a `HashShape` (literal pairs) or a `Nominal[Hash, [K, V]]`.
401
+ def hash_shape_key_values(type)
402
+ case type
403
+ when Type::HashShape
404
+ return [[], []] if type.pairs.empty?
405
+
406
+ [[key_union_for(type.pairs.keys)], type.pairs.values]
407
+ when Type::Nominal
408
+ type.class_name == "Hash" && type.type_args.size == 2 ? [[type.type_args[0]], [type.type_args[1]]] : [[], []]
409
+ when Type::Union
410
+ type.members.each_with_object([[], []]) do |m, (ks, vs)|
411
+ mk, mv = hash_shape_key_values(m)
412
+ ks.concat(mk)
413
+ vs.concat(mv)
414
+ end
415
+ else
416
+ [[], []]
417
+ end
418
+ end
283
419
  end
284
420
  end
285
421
  end