rigortype 0.1.10 → 0.1.12

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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/baseline_command.rb +4 -3
  7. data/lib/rigor/cli/plugins_command.rb +308 -0
  8. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  9. data/lib/rigor/cli.rb +44 -3
  10. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  11. data/lib/rigor/inference/expression_typer.rb +69 -30
  12. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  13. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  14. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  15. data/lib/rigor/inference/mutation_widening.rb +285 -0
  16. data/lib/rigor/inference/narrowing.rb +72 -4
  17. data/lib/rigor/inference/scope_indexer.rb +409 -12
  18. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  19. data/lib/rigor/scope.rb +181 -4
  20. data/lib/rigor/version.rb +1 -1
  21. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  22. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  23. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  24. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  25. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  26. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
  27. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
  28. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
  29. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
  30. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
  32. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
  33. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
  34. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
  35. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  36. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  37. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  38. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  39. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  40. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  41. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
  42. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  43. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  44. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  45. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
  46. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
  47. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
  48. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  49. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  50. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  53. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  54. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  55. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
  56. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  57. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
  58. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  59. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  60. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  62. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  63. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  64. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  65. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  66. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  67. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  68. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  69. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  70. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  71. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  72. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  73. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  74. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  75. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  76. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  77. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  78. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  79. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  80. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  81. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  82. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  83. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  84. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  85. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  86. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  87. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  88. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  89. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  90. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  91. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  92. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  93. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
  94. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  96. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
  97. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  98. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
  99. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  100. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  101. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  102. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
  103. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
  104. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
  105. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  106. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  107. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  108. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  109. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  110. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  111. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  112. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  113. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  114. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  115. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  116. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  117. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  118. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  119. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  120. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  121. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  122. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  123. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  124. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  125. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  126. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  127. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  128. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  129. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  130. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  131. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  132. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  133. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  134. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  135. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  136. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  137. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  138. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  139. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  140. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  141. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  142. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  143. data/sig/rigor/scope.rbs +22 -0
  144. metadata +157 -1
@@ -8,9 +8,11 @@ require_relative "../analysis/fact_store"
8
8
  require_relative "../source/node_walker"
9
9
  require_relative "block_parameter_binder"
10
10
  require_relative "closure_escape_analyzer"
11
+ require_relative "indexed_narrowing"
11
12
  require_relative "method_dispatcher"
12
13
  require_relative "method_parameter_binder"
13
14
  require_relative "multi_target_binder"
15
+ require_relative "mutation_widening"
14
16
  require_relative "narrowing"
15
17
 
16
18
  module Rigor
@@ -72,6 +74,7 @@ module Rigor
72
74
  Prism::GlobalVariableOrWriteNode => :eval_global_or_write,
73
75
  Prism::GlobalVariableAndWriteNode => :eval_global_and_write,
74
76
  Prism::GlobalVariableOperatorWriteNode => :eval_global_operator_write,
77
+ Prism::IndexOrWriteNode => :eval_index_or_write,
75
78
  Prism::MultiWriteNode => :eval_multi_write,
76
79
  Prism::IfNode => :eval_if,
77
80
  Prism::UnlessNode => :eval_unless,
@@ -300,6 +303,53 @@ module Rigor
300
303
  end
301
304
  end
302
305
 
306
+ # `receiver[key] ||= default` — the Redmine `Query#as_params`
307
+ # idiom (ROADMAP § Future cycles / Type-language / engine —
308
+ # "Indexed-collection narrowing through `Hash[k] ||= default`").
309
+ # After the `||=`, the next read at `receiver[key]` is known
310
+ # non-nil; the next `<<` / `[]=` / other mutator runs against
311
+ # a Tuple / Hash carrier instead of the `Constant[nil]` an
312
+ # empty `HashShape{}` lookup would otherwise fold to.
313
+ #
314
+ # The handler:
315
+ # 1. Types the equivalent `receiver[key]` read under the
316
+ # entry scope (so any previously-recorded narrowing for
317
+ # the same address is already applied).
318
+ # 2. Types the rvalue under the entry scope.
319
+ # 3. Computes `union(narrow_truthy(current), rhs)` — the
320
+ # standard `||=` result shape used by locals / ivars /
321
+ # cvars / globals.
322
+ # 4. Records the result type in the post-scope as a
323
+ # narrowing keyed on `(receiver_kind, receiver_name,
324
+ # literal_key)` when both receiver and key are stable
325
+ # (see {Inference::IndexedNarrowing}). Unstable shapes
326
+ # fall through to "no scope effect", matching the old
327
+ # `Prism::IndexOrWriteNode` default-branch behaviour.
328
+ #
329
+ # The expression value is the result type, matching Ruby's
330
+ # semantics: `(x = params[:f] ||= []); x` observes the
331
+ # post-`||=` value, not the rvalue alone.
332
+ def eval_index_or_write(node)
333
+ rhs_type, post_rhs = sub_eval(node.value, scope)
334
+ current_type = scope.type_of(node, tracer: tracer)
335
+ result_type = Type::Combinator.union(Narrowing.narrow_truthy(current_type), rhs_type)
336
+
337
+ key_node = first_index_argument(node)
338
+ address = key_node && IndexedNarrowing.stable_address(node.receiver, key_node)
339
+ post = post_rhs
340
+ post = post.with_indexed_narrowing(*address, result_type) if address
341
+
342
+ [result_type, post]
343
+ end
344
+
345
+ def first_index_argument(node)
346
+ args = node.arguments
347
+ return nil if args.nil?
348
+
349
+ list = args.respond_to?(:arguments) ? args.arguments : args
350
+ list.first
351
+ end
352
+
303
353
  def dispatch_operator(current, rhs, operator)
304
354
  result = MethodDispatcher.dispatch(
305
355
  receiver_type: current,
@@ -519,8 +569,30 @@ module Rigor
519
569
  # joined exit scope so locals bound exclusively in `ensure` stay
520
570
  # observable.
521
571
  def eval_begin(node)
522
- primary_type, primary_scope = eval_begin_primary(node)
523
- rescue_chain = collect_rescue_chain_results(node.rescue_clause, scope)
572
+ entry = scope
573
+ primary_type, primary_scope = eval_begin_primary_under(node, entry)
574
+ rescue_chain = collect_rescue_chain_results(node.rescue_clause, entry)
575
+
576
+ # B2.1 — retry-edge widening. When any rescue body
577
+ # contains `Prism::RetryNode`, control re-enters the
578
+ # primary body with the rescue arm's rebinds visible.
579
+ # Today's flow loses that effect, so a counter like
580
+ # `tries = 0; ...; rescue; tries += 1; retry; end`
581
+ # observes `tries: Constant[0]` inside the body and any
582
+ # `tries > 100` predicate folds to always-falsey.
583
+ # The fix: widen rebound locals / ivars in any
584
+ # retry-emitting arm to their Nominal envelope (Constant
585
+ # → Nominal[<class>], Tuple → Array, HashShape → Hash),
586
+ # then re-evaluate primary body AND rescue chain once
587
+ # under the widened entry. Nominal envelope is the
588
+ # maximally widened form so the re-evaluation converges
589
+ # in one step.
590
+ widened_entry = widen_entry_for_retry(entry, rescue_chain)
591
+ if widened_entry
592
+ primary_type, primary_scope = eval_begin_primary_under(node, widened_entry)
593
+ rescue_chain = collect_rescue_chain_results(node.rescue_clause, widened_entry)
594
+ end
595
+
524
596
  # Rescue arms whose body unconditionally exits (`return`,
525
597
  # `next`, `break`, `raise`, `throw`, `exit`, `abort`,
526
598
  # `fail`) contribute neither a type fragment NOR a scope
@@ -556,11 +628,15 @@ module Rigor
556
628
  # body's scope effects still apply because the body did run
557
629
  # before the else.
558
630
  def eval_begin_primary(node)
631
+ eval_begin_primary_under(node, scope)
632
+ end
633
+
634
+ def eval_begin_primary_under(node, entry_scope)
559
635
  body_type, body_scope =
560
636
  if node.statements
561
- sub_eval(node.statements, scope)
637
+ sub_eval(node.statements, entry_scope)
562
638
  else
563
- [Type::Combinator.constant_of(nil), scope]
639
+ [Type::Combinator.constant_of(nil), entry_scope]
564
640
  end
565
641
 
566
642
  if node.else_clause
@@ -571,6 +647,105 @@ module Rigor
571
647
  end
572
648
  end
573
649
 
650
+ # B2.1 — return a widened entry scope when at least one
651
+ # rescue arm in `rescue_chain` contains a `Prism::RetryNode`
652
+ # AND that arm rebinds at least one local or ivar relative
653
+ # to the original entry. Returns nil when no widening is
654
+ # needed (no retry, or no rebinds reachable across the
655
+ # retry edge).
656
+ #
657
+ # Always-safe: the widening can only LOSE precision; it
658
+ # never invents a fact (Nominal envelope is a superset of
659
+ # the Constant / shape carrier it widens from). Convergent
660
+ # in one step because Nominal envelope is the maximally
661
+ # widened form against the engine's current carrier set.
662
+ def widen_entry_for_retry(entry_scope, rescue_chain)
663
+ widened = nil
664
+ rescue_chain.each do |(_arm_type, arm_post_scope), arm_node|
665
+ next unless arm_contains_retry?(arm_node)
666
+
667
+ accumulator = widened || entry_scope
668
+ accumulator = absorb_retry_rebinds(accumulator, entry_scope, arm_post_scope)
669
+ widened = accumulator
670
+ end
671
+ return nil if widened.nil? || widened == entry_scope
672
+
673
+ widened
674
+ end
675
+
676
+ def arm_contains_retry?(node)
677
+ return false unless node.is_a?(Prism::Node)
678
+ return true if node.is_a?(Prism::RetryNode)
679
+ # Don't descend into nested blocks / defs / classes /
680
+ # modules — a `retry` inside a nested method body or
681
+ # block targets its own enclosing `begin`, not this one.
682
+ return false if node.is_a?(Prism::DefNode) ||
683
+ node.is_a?(Prism::ClassNode) ||
684
+ node.is_a?(Prism::ModuleNode) ||
685
+ node.is_a?(Prism::BlockNode)
686
+
687
+ node.compact_child_nodes.any? { |c| arm_contains_retry?(c) }
688
+ end
689
+
690
+ def absorb_retry_rebinds(accumulator, entry_scope, arm_post_scope)
691
+ scope_acc = accumulator
692
+ # Walk every local visible in either side, compare types,
693
+ # widen to Nominal envelope on a difference.
694
+ local_keys = arm_post_scope.locals.keys | entry_scope.locals.keys
695
+ local_keys.each do |name|
696
+ pre = entry_scope.local(name)
697
+ post = arm_post_scope.local(name)
698
+ next if pre == post || post.nil?
699
+
700
+ widened = retry_widened_type(pre, post)
701
+ scope_acc = scope_acc.with_local(name, widened)
702
+ end
703
+ ivar_keys = arm_post_scope.ivars.keys | entry_scope.ivars.keys
704
+ ivar_keys.each do |name|
705
+ pre = entry_scope.ivar(name)
706
+ post = arm_post_scope.ivar(name)
707
+ next if pre == post || post.nil?
708
+
709
+ widened = retry_widened_type(pre, post)
710
+ scope_acc = scope_acc.with_ivar(name, widened)
711
+ end
712
+ scope_acc
713
+ end
714
+
715
+ def retry_widened_type(pre, post)
716
+ # `pre` is nil when the local was introduced inside the
717
+ # rescue body. The retry edge brings it back into the
718
+ # primary body's entry — widen the post type itself.
719
+ envelope = nominal_envelope_for(post)
720
+ return envelope if pre.nil?
721
+
722
+ nominal_envelope_for(Type::Combinator.union(pre, envelope))
723
+ end
724
+
725
+ # Nominal envelope of a value type: widens Constant /
726
+ # Tuple / HashShape carriers to the underlying class's
727
+ # `Nominal`, preserving everything else (`Nominal`,
728
+ # `Union` of non-shape members, `Top`, `Dynamic`, `Bot`).
729
+ # Union members are walked individually.
730
+ def nominal_envelope_for(type)
731
+ members = type.is_a?(Type::Union) ? type.members : [type]
732
+ widened = members.map { |m| nominal_envelope_member(m) }
733
+ Type::Combinator.union(*widened)
734
+ end
735
+
736
+ def nominal_envelope_member(member)
737
+ case member
738
+ when Type::Constant
739
+ Type::Combinator.nominal_of(member.value.class.name)
740
+ when Type::Tuple
741
+ MutationWidening.widen_tuple(member)
742
+ when Type::HashShape
743
+ MutationWidening.widen_hash_shape(member)
744
+ else
745
+ member
746
+ end
747
+ end
748
+
574
749
  def collect_rescue_chain_results(rescue_node, entry_scope)
575
750
  results = []
576
751
  current = rescue_node
@@ -876,9 +1051,86 @@ module Rigor
876
1051
  post_scope = apply_rbs_extended_assertions(node, post_scope)
877
1052
  post_scope = apply_plugin_assertions(node, post_scope)
878
1053
  post_scope = apply_rspec_matcher_narrowing(node, post_scope)
1054
+ # Flow-folding G1 / G2 — widen a local- or instance-variable
1055
+ # binding when the call is an in-place mutator on it (e.g.
1056
+ # `arms << x`, `@tags << hashtag`). Stops a literal-shape
1057
+ # carrier (`Tuple` / `HashShape`) from outliving its
1058
+ # justification when the value is mutated. Always-safe
1059
+ # (loses precision, never invents facts).
1060
+ post_scope = MutationWidening.widen_after_call(call_node: node, current_scope: post_scope)
1061
+ # And the same widening for outer-scope locals / ivars
1062
+ # mutated inside the block body (`items.each { |x| arr << x }`):
1063
+ # the block lives in a child scope so without an explicit
1064
+ # propagation step the outer `arr` keeps its pre-mutation
1065
+ # binding. Sound for the same reason — only ever LOSES
1066
+ # precision — so blindly applying is safe regardless of
1067
+ # whether the block actually runs.
1068
+ post_scope = MutationWidening.widen_after_block(call_node: node, outer_scope: post_scope)
1069
+ # Indexed-collection narrowing — drop any
1070
+ # `receiver[key] ||= default` narrowing the analyzer
1071
+ # recorded earlier when an intervening `[]=` writes the
1072
+ # same slot or any other mutator runs against the
1073
+ # receiver. Always-safe (only forgets; never invents).
1074
+ post_scope = IndexedNarrowing.invalidate_after_call(call_node: node, current_scope: post_scope)
1075
+ # Single-hop method-chain narrowing — drop every
1076
+ # `(receiver, *)` chain narrowing rooted at the call's
1077
+ # outer stable receiver (any-call-against-the-root
1078
+ # invalidation rule, B2 from the slice's design
1079
+ # notes). Calls whose outer receiver is itself a chain
1080
+ # node (e.g. `x.last << y`) do NOT drop narrowings
1081
+ # keyed on `x` — only direct calls against the root
1082
+ # variable invalidate the chain.
1083
+ post_scope = IndexedNarrowing.invalidate_chain_after_call(call_node: node, current_scope: post_scope)
1084
+ # B2.2 — intervening method call ivar invalidation.
1085
+ # An implicit-self / self-receiver call could mutate any
1086
+ # ivar of the enclosing class (we cannot prove purity
1087
+ # without an effect system). Reset each ivar whose
1088
+ # current local binding has narrowed below the class-ivar
1089
+ # seed back to the seed itself, so a subsequent
1090
+ # `if @flag` predicate observes the seed's union (not the
1091
+ # pre-call narrowed value). Always-safe (only widens; no
1092
+ # new facts). See [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md)
1093
+ # § "Flow-folding" — G2 intervening-call case.
1094
+ post_scope = invalidate_ivars_for_intervening_call(node, post_scope)
879
1095
  [call_type, post_scope]
880
1096
  end
881
1097
 
1098
+ # Returns a scope with each ivar's narrowed local binding
1099
+ # widened back to its class-ivar seed value when the call
1100
+ # is one that could plausibly mutate ivars on the enclosing
1101
+ # class (implicit-self or explicit `self.foo`). External-
1102
+ # receiver calls (`obj.method`) cannot reach the caller's
1103
+ # ivars; they pass through unchanged.
1104
+ def invalidate_ivars_for_intervening_call(call_node, current_scope)
1105
+ return current_scope unless intervening_call_candidate?(call_node)
1106
+
1107
+ class_name = enclosing_class_name_for(current_scope.self_type)
1108
+ return current_scope if class_name.nil?
1109
+
1110
+ seed = current_scope.class_ivars_for(class_name)
1111
+ return current_scope if seed.empty?
1112
+
1113
+ seed.reduce(current_scope) do |acc, (ivar_name, seed_type)|
1114
+ local_type = current_scope.ivar(ivar_name)
1115
+ next acc if local_type.nil? || local_type == seed_type
1116
+
1117
+ acc.with_ivar(ivar_name, Type::Combinator.union(local_type, seed_type))
1118
+ end
1119
+ end
1120
+
1121
+ def intervening_call_candidate?(call_node)
1122
+ return false unless call_node.is_a?(Prism::CallNode)
1123
+
1124
+ receiver = call_node.receiver
1125
+ receiver.nil? || receiver.is_a?(Prism::SelfNode)
1126
+ end
1127
+
1128
+ def enclosing_class_name_for(self_type)
1129
+ case self_type
1130
+ when Type::Nominal, Type::Singleton then self_type.class_name
1131
+ end
1132
+ end
1133
+
882
1134
  def evaluate_def_arguments(call_node)
883
1135
  args = call_node.arguments
884
1136
  return unless args.respond_to?(:arguments)
data/lib/rigor/scope.rb CHANGED
@@ -22,12 +22,50 @@ module Rigor
22
22
  :discovered_classes, :in_source_constants, :discovered_methods,
23
23
  :discovered_def_nodes, :discovered_method_visibilities,
24
24
  :discovered_superclasses, :discovered_includes,
25
+ :indexed_narrowings, :method_chain_narrowings,
25
26
  :source_path
26
27
 
28
+ # Narrowing key for an indexed read `receiver[key]` where both
29
+ # the receiver and the key are stable enough to address. The
30
+ # value of the map at this key is the narrowed type the next
31
+ # read at the same address MUST observe.
32
+ #
33
+ # - `receiver_kind` ∈ `{:local, :ivar}` — the analyzer only
34
+ # tracks reads against a local or instance variable today.
35
+ # - `receiver_name` is the variable's Symbol.
36
+ # - `key` is the Ruby value of the literal index (Symbol /
37
+ # String / Integer). Non-literal keys (`params[field]`) are
38
+ # not recorded; they have no stable address.
39
+ IndexedKey = Data.define(:receiver_kind, :receiver_name, :key)
40
+
41
+ # Narrowing key for a no-arg / no-block method-call chain
42
+ # `receiver.method_name` (a "single-hop" chain per A1 from the
43
+ # ROADMAP § Future cycles slice). The value of the map at this
44
+ # key is the narrowed type the next read of the same chain
45
+ # MUST observe — typically the post-`is_a?(C)` narrowing
46
+ # established on a predicate edge.
47
+ #
48
+ # - `receiver_kind` ∈ `{:local, :ivar}` — the analyzer only
49
+ # tracks chains rooted at a local or instance variable
50
+ # today (Law-of-Demeter-style single-hop).
51
+ # - `receiver_name` is the root variable's Symbol.
52
+ # - `method_name` is the no-arg method invoked on the root.
53
+ #
54
+ # Chains with arguments (`x.first(3)`), with a block
55
+ # (`x.detect { ... }`), or with intermediate links
56
+ # (`x.foo.bar`) are NOT recorded; each loses stability for
57
+ # different reasons (args / block alter the call's return;
58
+ # multi-hop loses the LoD guarantee).
59
+ ChainKey = Data.define(:receiver_kind, :receiver_name, :method_name)
60
+
27
61
  EMPTY_DECLARED_TYPES = {}.compare_by_identity.freeze
28
62
  EMPTY_VAR_BINDINGS = {}.freeze
29
63
  EMPTY_CLASS_BINDINGS = {}.freeze
30
- private_constant :EMPTY_DECLARED_TYPES, :EMPTY_VAR_BINDINGS, :EMPTY_CLASS_BINDINGS
64
+ EMPTY_INDEXED_NARROWINGS = {}.freeze
65
+ EMPTY_CHAIN_NARROWINGS = {}.freeze
66
+ private_constant :EMPTY_DECLARED_TYPES, :EMPTY_VAR_BINDINGS,
67
+ :EMPTY_CLASS_BINDINGS, :EMPTY_INDEXED_NARROWINGS,
68
+ :EMPTY_CHAIN_NARROWINGS
31
69
 
32
70
  class << self
33
71
  def empty(environment: Environment.default, source_path: nil)
@@ -54,6 +92,8 @@ module Rigor
54
92
  discovered_method_visibilities: EMPTY_CLASS_BINDINGS,
55
93
  discovered_superclasses: EMPTY_CLASS_BINDINGS,
56
94
  discovered_includes: EMPTY_CLASS_BINDINGS,
95
+ indexed_narrowings: EMPTY_INDEXED_NARROWINGS,
96
+ method_chain_narrowings: EMPTY_CHAIN_NARROWINGS,
57
97
  source_path: nil
58
98
  )
59
99
  @environment = environment
@@ -74,6 +114,8 @@ module Rigor
74
114
  @discovered_method_visibilities = discovered_method_visibilities
75
115
  @discovered_superclasses = discovered_superclasses
76
116
  @discovered_includes = discovered_includes
117
+ @indexed_narrowings = indexed_narrowings
118
+ @method_chain_narrowings = method_chain_narrowings
77
119
  @source_path = source_path
78
120
  freeze
79
121
  end
@@ -85,7 +127,19 @@ module Rigor
85
127
  def with_local(name, type)
86
128
  new_locals = @locals.merge(name.to_sym => type).freeze
87
129
  new_fact_store = fact_store.invalidate_target(Analysis::FactStore::Target.local(name))
88
- rebuild(locals: new_locals, fact_store: new_fact_store)
130
+ # Rebinding `name` invalidates every "after `receiver[key]
131
+ # ||= default`" narrowing keyed on it — the slot at `name[*]`
132
+ # is reachable through the old binding only, so the
133
+ # next read against the new binding does not inherit the
134
+ # earlier non-nil guarantee. The same logic applies to
135
+ # method-chain narrowings: `x.last` after `x = something_new`
136
+ # is a call on the new binding and any prior `is_a?`-driven
137
+ # narrowing keyed on `(local, :x, :last)` no longer holds.
138
+ new_indexed_narrowings = drop_indexed_narrowings_for(:local, name)
139
+ new_chain_narrowings = drop_chain_narrowings_for(:local, name)
140
+ rebuild(locals: new_locals, fact_store: new_fact_store,
141
+ indexed_narrowings: new_indexed_narrowings,
142
+ method_chain_narrowings: new_chain_narrowings)
89
143
  end
90
144
 
91
145
  def with_fact(fact)
@@ -150,7 +204,11 @@ module Rigor
150
204
  end
151
205
 
152
206
  def with_ivar(name, type)
153
- rebuild(ivars: @ivars.merge(name.to_sym => type).freeze)
207
+ new_indexed_narrowings = drop_indexed_narrowings_for(:ivar, name)
208
+ new_chain_narrowings = drop_chain_narrowings_for(:ivar, name)
209
+ rebuild(ivars: @ivars.merge(name.to_sym => type).freeze,
210
+ indexed_narrowings: new_indexed_narrowings,
211
+ method_chain_narrowings: new_chain_narrowings)
154
212
  end
155
213
 
156
214
  def with_cvar(name, type)
@@ -344,6 +402,79 @@ module Rigor
344
402
  rebuild(discovered_method_visibilities: table)
345
403
  end
346
404
 
405
+ # Closes the "`params[:f] ||= []; params[:f] << x`" precision
406
+ # gap (ROADMAP § Type-language / engine — indexed-collection
407
+ # narrowing through `Hash[k] ||= default`). After
408
+ # `receiver[key] ||= default`, the next read at `receiver[key]`
409
+ # is known non-nil; recording the post-`||=` type keyed on
410
+ # `(receiver_kind, receiver_name, literal_key)` lets the
411
+ # ExpressionTyper's `[]` dispatch hand back the narrowed
412
+ # type. Receiver-rebind and `[]=`/mutator invalidation rules
413
+ # are documented at the call sites in
414
+ # `Inference::StatementEvaluator`.
415
+ def indexed_narrowing(receiver_kind, receiver_name, key)
416
+ @indexed_narrowings[indexed_key(receiver_kind, receiver_name, key)]
417
+ end
418
+
419
+ def with_indexed_narrowing(receiver_kind, receiver_name, key, type)
420
+ new_table = @indexed_narrowings.merge(
421
+ indexed_key(receiver_kind, receiver_name, key) => type
422
+ ).freeze
423
+ rebuild(indexed_narrowings: new_table)
424
+ end
425
+
426
+ def without_indexed_narrowing(receiver_kind, receiver_name, key)
427
+ lookup = indexed_key(receiver_kind, receiver_name, key)
428
+ return self unless @indexed_narrowings.key?(lookup)
429
+
430
+ new_table = @indexed_narrowings.reject { |k, _| k == lookup }.freeze
431
+ rebuild(indexed_narrowings: new_table)
432
+ end
433
+
434
+ def without_indexed_narrowings_for(receiver_kind, receiver_name)
435
+ new_table = drop_indexed_narrowings_for(receiver_kind, receiver_name)
436
+ return self if new_table.equal?(@indexed_narrowings)
437
+
438
+ rebuild(indexed_narrowings: new_table)
439
+ end
440
+
441
+ # Closes the "stable receiver method-chain narrowing" gap
442
+ # (ROADMAP § Future cycles / Type-language / engine —
443
+ # "Method-call receiver narrowing across stable receivers";
444
+ # 2026-05-28 Redmine survey). After `if x.last.is_a?(Array)`
445
+ # the dominated body's `x.last` reads MUST observe the
446
+ # truthy-narrowed type; the same chain reaching the falsey
447
+ # edge observes the negative narrowing.
448
+ #
449
+ # Address shape mirrors {.indexed_narrowing}: stable root
450
+ # variable + no-arg single-hop method name. See
451
+ # {ChainKey} for the precise contract.
452
+ def method_chain_narrowing(receiver_kind, receiver_name, method_name)
453
+ @method_chain_narrowings[chain_key(receiver_kind, receiver_name, method_name)]
454
+ end
455
+
456
+ def with_method_chain_narrowing(receiver_kind, receiver_name, method_name, type)
457
+ new_table = @method_chain_narrowings.merge(
458
+ chain_key(receiver_kind, receiver_name, method_name) => type
459
+ ).freeze
460
+ rebuild(method_chain_narrowings: new_table)
461
+ end
462
+
463
+ def without_method_chain_narrowing(receiver_kind, receiver_name, method_name)
464
+ lookup = chain_key(receiver_kind, receiver_name, method_name)
465
+ return self unless @method_chain_narrowings.key?(lookup)
466
+
467
+ new_table = @method_chain_narrowings.reject { |k, _| k == lookup }.freeze
468
+ rebuild(method_chain_narrowings: new_table)
469
+ end
470
+
471
+ def without_method_chain_narrowings_for(receiver_kind, receiver_name)
472
+ new_table = drop_chain_narrowings_for(receiver_kind, receiver_name)
473
+ return self if new_table.equal?(@method_chain_narrowings)
474
+
475
+ rebuild(method_chain_narrowings: new_table)
476
+ end
477
+
347
478
  def facts_for(target: nil, bucket: nil)
348
479
  fact_store.facts_for(target: target, bucket: bucket)
349
480
  end
@@ -395,7 +526,9 @@ module Rigor
395
526
  self_type == other.self_type &&
396
527
  @ivars == other.ivars &&
397
528
  @cvars == other.cvars &&
398
- @globals == other.globals
529
+ @globals == other.globals &&
530
+ @indexed_narrowings == other.indexed_narrowings &&
531
+ @method_chain_narrowings == other.method_chain_narrowings
399
532
  end
400
533
  alias eql? ==
401
534
 
@@ -414,6 +547,8 @@ module Rigor
414
547
  discovered_method_visibilities: @discovered_method_visibilities,
415
548
  discovered_superclasses: @discovered_superclasses,
416
549
  discovered_includes: @discovered_includes,
550
+ indexed_narrowings: @indexed_narrowings,
551
+ method_chain_narrowings: @method_chain_narrowings,
417
552
  source_path: @source_path
418
553
  )
419
554
  self.class.new(
@@ -430,6 +565,8 @@ module Rigor
430
565
  discovered_method_visibilities: discovered_method_visibilities,
431
566
  discovered_superclasses: discovered_superclasses,
432
567
  discovered_includes: discovered_includes,
568
+ indexed_narrowings: indexed_narrowings,
569
+ method_chain_narrowings: method_chain_narrowings,
433
570
  source_path: source_path
434
571
  )
435
572
  end
@@ -459,9 +596,49 @@ module Rigor
459
596
  discovered_method_visibilities: discovered_method_visibilities,
460
597
  discovered_superclasses: discovered_superclasses,
461
598
  discovered_includes: discovered_includes,
599
+ indexed_narrowings: join_bindings(@indexed_narrowings, other.indexed_narrowings),
600
+ method_chain_narrowings: join_bindings(@method_chain_narrowings, other.method_chain_narrowings),
462
601
  source_path: source_path
463
602
  )
464
603
  end
604
+
605
+ def indexed_key(receiver_kind, receiver_name, key)
606
+ IndexedKey.new(
607
+ receiver_kind: receiver_kind.to_sym,
608
+ receiver_name: receiver_name.to_sym,
609
+ key: key
610
+ )
611
+ end
612
+
613
+ def chain_key(receiver_kind, receiver_name, method_name)
614
+ ChainKey.new(
615
+ receiver_kind: receiver_kind.to_sym,
616
+ receiver_name: receiver_name.to_sym,
617
+ method_name: method_name.to_sym
618
+ )
619
+ end
620
+
621
+ def drop_indexed_narrowings_for(receiver_kind, receiver_name)
622
+ return @indexed_narrowings if @indexed_narrowings.empty?
623
+
624
+ sym_kind = receiver_kind.to_sym
625
+ sym_name = receiver_name.to_sym
626
+ filtered = @indexed_narrowings.reject do |k, _|
627
+ k.receiver_kind == sym_kind && k.receiver_name == sym_name
628
+ end
629
+ filtered.size == @indexed_narrowings.size ? @indexed_narrowings : filtered.freeze
630
+ end
631
+
632
+ def drop_chain_narrowings_for(receiver_kind, receiver_name)
633
+ return @method_chain_narrowings if @method_chain_narrowings.empty?
634
+
635
+ sym_kind = receiver_kind.to_sym
636
+ sym_name = receiver_name.to_sym
637
+ filtered = @method_chain_narrowings.reject do |k, _|
638
+ k.receiver_kind == sym_kind && k.receiver_name == sym_name
639
+ end
640
+ filtered.size == @method_chain_narrowings.size ? @method_chain_narrowings : filtered.freeze
641
+ end
465
642
  end
466
643
  # rubocop:enable Metrics/ClassLength,Metrics/ParameterLists
467
644
  end
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.1.10"
4
+ VERSION = "0.1.12"
5
5
  end