rigortype 0.1.17 → 0.1.18

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
  4. data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
  5. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +18 -1
  6. data/lib/rigor/analysis/check_rules.rb +34 -6
  7. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  8. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  9. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  10. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  11. data/lib/rigor/analysis/runner.rb +160 -1190
  12. data/lib/rigor/analysis/worker_session.rb +47 -8
  13. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  14. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  15. data/lib/rigor/cache/store.rb +46 -13
  16. data/lib/rigor/cli/check_command.rb +705 -0
  17. data/lib/rigor/cli/ci_detector.rb +94 -0
  18. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  19. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  20. data/lib/rigor/cli/trace_command.rb +143 -0
  21. data/lib/rigor/cli/trace_renderer.rb +310 -0
  22. data/lib/rigor/cli.rb +15 -614
  23. data/lib/rigor/configuration.rb +9 -6
  24. data/lib/rigor/environment/rbs_loader.rb +53 -68
  25. data/lib/rigor/environment.rb +1 -1
  26. data/lib/rigor/inference/acceptance.rb +10 -0
  27. data/lib/rigor/inference/expression_typer.rb +28 -62
  28. data/lib/rigor/inference/flow_tracer.rb +180 -0
  29. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  31. data/lib/rigor/inference/method_dispatcher.rb +115 -54
  32. data/lib/rigor/inference/narrowing.rb +60 -0
  33. data/lib/rigor/inference/scope_indexer.rb +75 -15
  34. data/lib/rigor/inference/statement_evaluator.rb +35 -52
  35. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  36. data/lib/rigor/plugin/base.rb +282 -41
  37. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  38. data/lib/rigor/plugin/registry.rb +263 -35
  39. data/lib/rigor/plugin.rb +1 -0
  40. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  41. data/lib/rigor/scope/discovery_index.rb +58 -0
  42. data/lib/rigor/scope.rb +67 -198
  43. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  44. data/lib/rigor/source/literals.rb +14 -0
  45. data/lib/rigor/type/combinator.rb +5 -0
  46. data/lib/rigor/version.rb +1 -1
  47. data/lib/rigor.rb +0 -1
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  49. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  50. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  53. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  54. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  55. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  56. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  57. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  58. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  59. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  60. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  61. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  62. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  63. data/sig/rigor/environment.rbs +0 -2
  64. data/sig/rigor/inference.rbs +5 -0
  65. data/sig/rigor/plugin/base.rbs +1 -2
  66. data/sig/rigor/scope.rbs +41 -29
  67. data/sig/rigor/source.rbs +1 -0
  68. data/skills/rigor-ci-setup/SKILL.md +319 -0
  69. metadata +15 -2
  70. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
@@ -6,6 +6,7 @@ require_relative "../flow_contribution"
6
6
  require_relative "../flow_contribution/merger"
7
7
  require_relative "../builtins/hkt_builtins"
8
8
  require_relative "../builtins/static_return_refinements"
9
+ require_relative "flow_tracer"
9
10
  require_relative "method_dispatcher/call_context"
10
11
  require_relative "method_dispatcher/constant_folding"
11
12
  require_relative "method_dispatcher/literal_string_folding"
@@ -73,9 +74,28 @@ module Rigor
73
74
  # @param environment [Rigor::Environment, nil] required for
74
75
  # RBS-backed dispatch; when nil only constant folding can fire.
75
76
  # @return [Rigor::Type, nil] inferred result type, or `nil` for "no rule".
76
- def dispatch(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
77
+ def dispatch(receiver_type:, method_name:, arg_types:,
77
78
  block_type: nil, environment: nil,
78
79
  call_node: nil, scope: nil)
80
+ result = resolve(
81
+ receiver_type: receiver_type, method_name: method_name, arg_types: arg_types,
82
+ block_type: block_type, environment: environment,
83
+ call_node: call_node, scope: scope
84
+ )
85
+ # `rigor trace` — record the dispatch outcome (resolved type, or
86
+ # the fail-soft `nil` the caller widens to `Dynamic[Top]`).
87
+ if FlowTracer.active?
88
+ FlowTracer.dispatch(
89
+ receiver: receiver_type, method_name: method_name, args: arg_types,
90
+ result: result, location: call_node&.location
91
+ )
92
+ end
93
+ result
94
+ end
95
+
96
+ def resolve(receiver_type:, method_name:, arg_types:, # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
97
+ block_type: nil, environment: nil,
98
+ call_node: nil, scope: nil)
79
99
  return nil if receiver_type.nil?
80
100
 
81
101
  # Build the call context once and thread it — unchanged —
@@ -217,7 +237,7 @@ module Rigor
217
237
  # introspection (`attr_reader`, `private`, ...) on
218
238
  # user classes without requiring the user to author
219
239
  # their own RBS.
220
- try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type, call_node)
240
+ try_user_class_fallback(receiver_type, environment, call_node, context)
221
241
  end
222
242
 
223
243
  # v0.1.3 — discovered-method dispatch tier. `scope` carries
@@ -298,12 +318,12 @@ module Rigor
298
318
  end
299
319
 
300
320
  # ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
301
- # slice 7. Walks every loaded plugin's
302
- # `#flow_contribution_for(call_node:, scope:)` hook,
303
- # collects the non-nil `FlowContribution` bundles, merges
304
- # them through `FlowContribution::Merger`, and returns
305
- # the merged `return_type` slot (or nil when no plugin
306
- # contributed a return type).
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).
307
327
  #
308
328
  # Plugins whose hook raises have their contribution
309
329
  # silently dropped for this call so the dispatch chain
@@ -643,23 +663,15 @@ module Rigor
643
663
  Type::Combinator.untyped
644
664
  end
645
665
 
666
+ # ADR-52 WD1 — the per-dispatch plugins × owns_receivers ×
667
+ # `class_ordering` walk moved into the compiled contribution
668
+ # table: the union is built once per registry (almost always
669
+ # empty → O(1) false) and per-class verdicts memoise per run.
646
670
  def plugin_owns_receiver?(class_name, environment)
647
671
  registry = environment&.plugin_registry
648
672
  return false if registry.nil? || registry.empty?
649
673
 
650
- registry.plugins.any? do |plugin|
651
- owns = plugin.manifest.owns_receivers # rigor:disable undefined-method
652
- owns.any? { |owner| receiver_matches_owner?(class_name, owner, environment) }
653
- end
654
- end
655
-
656
- def receiver_matches_owner?(class_name, owner, environment)
657
- return true if class_name == owner
658
-
659
- ordering = environment.class_ordering(class_name, owner)
660
- %i[equal subclass].include?(ordering)
661
- rescue StandardError
662
- false
674
+ registry.contribution_index.owns_receiver?(class_name, environment)
663
675
  end
664
676
 
665
677
  def dep_source_class_name(receiver_type)
@@ -668,11 +680,11 @@ module Rigor
668
680
  end
669
681
  end
670
682
 
671
- # ADR-37 slice 2 — gathers each plugin's return-type contribution
672
- # from BOTH the narrow `dynamic_return` DSL (receiver-gated, wrapped
673
- # as a return-only `FlowContribution`) and the legacy
674
- # `flow_contribution_for` escape valve, so migrated and unmigrated
675
- # plugins compose through the same merger.
683
+ # ADR-37 slice 2 / ADR-52 WD3 — gathers each plugin's return-type
684
+ # contribution from the gated `dynamic_return` DSL, wrapped as a
685
+ # return-only `FlowContribution` for the shared merger. (The legacy
686
+ # ungated `flow_contribution_for` escape valve was deleted once its
687
+ # five users migrated.)
676
688
  EMPTY_CONTRIBUTIONS = [].freeze
677
689
  private_constant :EMPTY_CONTRIBUTIONS
678
690
 
@@ -682,31 +694,48 @@ module Rigor
682
694
  #
683
695
  # 1. Only the plugins that *structurally* implement a per-call path
684
696
  # are visited — `registry.contribution_index.for_method_dispatch`
685
- # is the registry-ordered subset overriding `flow_contribution_for`
686
- # or declaring a `dynamic_return` (GitLab: 2 of 11). Iterating the
687
- # subset in registry order, and gating each path by membership,
688
- # yields the exact same contributions in the same order as
689
- # visiting every plugin would (a skipped plugin's call returns
690
- # nil/[] anyway). The receiver-class ancestry match still happens
691
- # per dispatch inside `dynamic_return_type`.
697
+ # is the registry-ordered subset declaring a `dynamic_return`.
698
+ # Iterating the subset in registry order, and gating each path by
699
+ # membership, yields the exact same contributions in the same
700
+ # order as visiting every plugin would (a skipped plugin's call
701
+ # returns nil/[] anyway). The receiver-class ancestry match still
702
+ # happens per dispatch inside `dynamic_return_type`.
692
703
  # 2. Contributions accumulate lazily — allocate only when one
693
704
  # actually appears, and share a frozen empty array otherwise. The
694
705
  # caller treats the result as read-only (`.empty?` / `Merger.merge`).
706
+ # 3. ADR-52 WD1 — method-name gates compiled at registry build. The
707
+ # global gate makes the common "no plugin cares about this call"
708
+ # case a single Set probe; the per-plugin gate skips a plugin
709
+ # whose `dynamic_return` rules are all `methods:`-gated on other
710
+ # names. A pruned consultation could only have returned nil, so
711
+ # contribution order and content are unchanged.
695
712
  def collect_plugin_contributions(registry, call_node, scope, receiver_type)
696
713
  index = registry.contribution_index
697
714
  relevant = index.for_method_dispatch
698
715
  return EMPTY_CONTRIBUTIONS if relevant.empty?
699
716
 
717
+ # `call_node` is not always a CallNode — the `&:symbol` block
718
+ # path dispatches with the `Prism::BlockArgumentNode` itself
719
+ # (`ExpressionTyper#symbol_block_return_type`). A bare `.name`
720
+ # here raised, and the raise was silently absorbed by
721
+ # `block_return_type_for`'s rescue, nil-ing the block type and
722
+ # flipping `select(&:p)`-style calls onto their no-block
723
+ # Enumerator overloads (caught by the GitLab corpus gate).
724
+ name = call_node.respond_to?(:name) ? call_node.name : nil
725
+ return EMPTY_CONTRIBUTIONS unless index.dispatch_candidate?(name)
726
+
727
+ collect_gated_contributions(index, relevant, name, call_node, scope, receiver_type)
728
+ end
729
+
730
+ # The post-gate walk, in registry order — the same order the
731
+ # ungated walk used.
732
+ def collect_gated_contributions(index, relevant, name, call_node, scope, receiver_type)
700
733
  result = nil
701
734
  relevant.each do |plugin|
702
- if index.flow?(plugin)
703
- legacy = plugin.flow_contribution_for(call_node: call_node, scope: scope)
704
- (result ||= []) << legacy if legacy.is_a?(FlowContribution)
705
- end
706
- if index.dynamic?(plugin)
707
- dynamic = plugin.dynamic_return_type(call_node: call_node, scope: scope, receiver_type: receiver_type)
708
- (result ||= []) << FlowContribution.new(return_type: dynamic) if dynamic
709
- end
735
+ next unless index.dynamic_candidate_for?(plugin, name)
736
+
737
+ dynamic = plugin.dynamic_return_type(call_node: call_node, scope: scope, receiver_type: receiver_type)
738
+ (result ||= []) << FlowContribution.new(return_type: dynamic) if dynamic
710
739
  rescue StandardError
711
740
  next
712
741
  end
@@ -735,13 +764,37 @@ module Rigor
735
764
  # filter the common cases first. Adding a precise tier is a
736
765
  # one-line append here rather than another link in a hand-written
737
766
  # `||` ladder.
738
- PRECISE_TIERS = Ractor.make_shareable([
739
- ConstantFolding, LiteralStringFolding, ShapeDispatch,
740
- FileFolding, ShellwordsFolding, MathFolding, TimeFolding,
741
- RegexpFolding, CGIFolding, URIFolding, SetFolding,
767
+ PRECISE_TIERS_HEAD = Ractor.make_shareable([
768
+ ConstantFolding, LiteralStringFolding, ShapeDispatch
769
+ ].freeze)
770
+ private_constant :PRECISE_TIERS_HEAD
771
+
772
+ # ADR-53 re-review follow-up (gate-by-held-key applied to the
773
+ # built-in tiers): the eight stdlib singleton folders are mutually
774
+ # exclusive — each fires only on `Singleton[<its class>]`, the
775
+ # first check in every `try_dispatch` — so at most one can match a
776
+ # given receiver and their relative trial order was never
777
+ # observable. Compiling them into a class-name table turns eight
778
+ # no-op trials per call into one Hash read, skipped entirely when
779
+ # the receiver is not a `Singleton` (the overwhelmingly common
780
+ # case). The table sits where the eight sat in the old flat list:
781
+ # after ShapeDispatch, before KernelDispatch.
782
+ STDLIB_SINGLETON_FOLDERS = Ractor.make_shareable({
783
+ "File" => FileFolding,
784
+ "Shellwords" => ShellwordsFolding,
785
+ "Math" => MathFolding,
786
+ "Time" => TimeFolding,
787
+ "Regexp" => RegexpFolding,
788
+ "CGI" => CGIFolding,
789
+ "URI" => URIFolding,
790
+ "Set" => SetFolding
791
+ }.freeze)
792
+ private_constant :STDLIB_SINGLETON_FOLDERS
793
+
794
+ PRECISE_TIERS_TAIL = Ractor.make_shareable([
742
795
  KernelDispatch, MethodFolding, BlockFolding
743
796
  ].freeze)
744
- private_constant :PRECISE_TIERS
797
+ private_constant :PRECISE_TIERS_TAIL
745
798
 
746
799
  def dispatch_precise_tiers(context)
747
800
  # ADR-48 — Data value folding runs ahead of meta-introspection:
@@ -756,14 +809,25 @@ module Rigor
756
809
  meta_result = try_meta_introspection(context.receiver, context.method_name, context.args)
757
810
  return meta_result if meta_result
758
811
 
759
- PRECISE_TIERS.each do |tier|
812
+ PRECISE_TIERS_HEAD.each do |tier|
813
+ result = tier.try_dispatch(context)
814
+ return result if result
815
+ end
816
+
817
+ receiver = context.receiver
818
+ if receiver.is_a?(Type::Singleton) && (folder = STDLIB_SINGLETON_FOLDERS[receiver.class_name])
819
+ result = folder.try_dispatch(context)
820
+ return result if result
821
+ end
822
+
823
+ PRECISE_TIERS_TAIL.each do |tier|
760
824
  result = tier.try_dispatch(context)
761
825
  return result if result
762
826
  end
763
827
  nil
764
828
  end
765
829
 
766
- def try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type, call_node = nil)
830
+ def try_user_class_fallback(receiver_type, environment, call_node, context)
767
831
  return nil if environment.nil?
768
832
 
769
833
  fallback_receiver = user_class_fallback_receiver(receiver_type, environment)
@@ -788,14 +852,11 @@ module Rigor
788
852
  # self / `self.`-receiver calls (`puts`, `raise`, `require`)
789
853
  # keep resolving — those are the fallback's intended targets.
790
854
  RbsDispatch.try_dispatch(
791
- CallContext.build(
855
+ context.with(
792
856
  receiver: fallback_receiver,
793
- method_name: method_name,
794
- args: arg_types,
795
- environment: environment,
796
- block_type: block_type,
797
857
  self_type_override: receiver_type,
798
- public_only: explicit_non_self_receiver?(call_node)
858
+ public_only: explicit_non_self_receiver?(call_node),
859
+ call_node: nil, scope: nil
799
860
  )
800
861
  )
801
862
  end
@@ -123,6 +123,66 @@ module Rigor
123
123
  end
124
124
  end
125
125
 
126
+ # Three-valued truthiness certainty of a predicate's type,
127
+ # derived from the truthy / falsey fragments above: `:truthy`
128
+ # when no inhabitant is falsey (the falsey fragment is `Bot`),
129
+ # `:falsey` when no inhabitant is truthy, nil when both
130
+ # fragments are inhabited — or when the type itself is nil /
131
+ # `Bot` (dead code is not a certainty claim). This is the single
132
+ # owner of the judgment both branch-elision consumers read
133
+ # (`ExpressionTyper#elide_or_union` on the value side,
134
+ # `StatementEvaluator#live_branch_for_if` on the scope side), so
135
+ # the type a dead branch is elided from and the scope that stops
136
+ # flowing through it can never disagree.
137
+ def predicate_certainty(type)
138
+ return nil if type.nil? || type.is_a?(Type::Bot)
139
+
140
+ truthy_bot = narrow_truthy(type).is_a?(Type::Bot)
141
+ falsey_bot = narrow_falsey(type).is_a?(Type::Bot)
142
+
143
+ return :falsey if truthy_bot && !falsey_bot
144
+ return :truthy if !truthy_bot && falsey_bot
145
+
146
+ nil
147
+ end
148
+
149
+ # Three-valued certainty of `C === subject` for a class / module
150
+ # `when` pattern, derived from {.narrow_class} /
151
+ # {.narrow_not_class}: `:no` when no inhabitant of the subject
152
+ # matches, `:yes` when every inhabitant matches, `:maybe`
153
+ # otherwise. The value-side counterpart of the scope narrowing
154
+ # {.case_when_scopes} performs for the same condition shape, kept
155
+ # here so the branch a `case` expression's type drops and the
156
+ # clause whose body scope goes dead derive from one judgment.
157
+ def class_pattern_certainty(subject_type, class_name, environment:)
158
+ truthy_bot = narrow_class(subject_type, class_name, environment: environment).is_a?(Type::Bot)
159
+ falsey_bot = narrow_not_class(subject_type, class_name, environment: environment).is_a?(Type::Bot)
160
+
161
+ return :no if truthy_bot && !falsey_bot
162
+ return :yes if !truthy_bot && falsey_bot
163
+
164
+ :maybe
165
+ end
166
+
167
+ # Classes whose `===` is plain value equality, so a literal
168
+ # `when` pattern against a pinned `Constant` subject is exact in
169
+ # both directions. Anything else keeps custom-`===` semantics
170
+ # and stays `:maybe` in {.value_pattern_certainty}.
171
+ VALUE_EQUALITY_CLASSES = [Integer, Float, Rational, Complex, String, Symbol,
172
+ TrueClass, FalseClass, NilClass].freeze
173
+
174
+ # Three-valued certainty of `<literal> === subject` for a
175
+ # value-equality literal pattern: exact (`:yes` / `:no`) only
176
+ # when the subject is itself a pinned `Constant` of a
177
+ # value-equality class; `:maybe` otherwise (the runtime value
178
+ # isn't pinned, or `===` may be user-defined).
179
+ def value_pattern_certainty(subject_type, pattern_value)
180
+ return :maybe unless subject_type.is_a?(Type::Constant)
181
+ return :maybe unless VALUE_EQUALITY_CLASSES.any? { |klass| subject_type.value.is_a?(klass) }
182
+
183
+ pattern_value == subject_type.value ? :yes : :no
184
+ end
185
+
126
186
  # Equality fragment of `type` against a trusted literal.
127
187
  #
128
188
  # String/Symbol/Integer equality narrows only when the current
@@ -68,16 +68,17 @@ module Rigor
68
68
  # collision — same-file declarations are the most
69
69
  # specific authority.
70
70
  merged_classes = default_scope.discovered_classes.merge(discovered_classes)
71
- seeded_scope = default_scope
72
- .with_declared_types(declared_types)
73
- .with_discovered_classes(merged_classes)
71
+ seeded_scope = default_scope.with_discovery(
72
+ default_scope.discovery.with(declared_types: declared_types,
73
+ discovered_classes: merged_classes)
74
+ )
74
75
 
75
76
  # Slice 7 phase 2. Pre-pass over every class/module body
76
77
  # to collect the per-class ivar accumulator. Seeded after
77
78
  # declared_types so the rvalue typer in the pre-pass can
78
79
  # see declaration overrides.
79
80
  class_ivars = build_class_ivar_index(root, seeded_scope)
80
- seeded_scope = seeded_scope.with_class_ivars(class_ivars)
81
+ seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(class_ivars: class_ivars))
81
82
 
82
83
  # Slice 7 phase 6. Same pre-pass shape for cvars (per
83
84
  # class) and globals (program-wide). Globals are also
@@ -86,9 +87,9 @@ module Rigor
86
87
  # not enter a method body) observe the precise type
87
88
  # without consulting the accumulator on every lookup.
88
89
  class_cvars = build_class_cvar_index(root, seeded_scope)
89
- seeded_scope = seeded_scope.with_class_cvars(class_cvars)
90
+ seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(class_cvars: class_cvars))
90
91
  program_globals = build_program_global_index(root, seeded_scope)
91
- seeded_scope = seeded_scope.with_program_globals(program_globals)
92
+ seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(program_globals: program_globals))
92
93
  program_globals.each { |name, type| seeded_scope = seeded_scope.with_global(name, type) }
93
94
 
94
95
  # Slice 7 phase 9. In-source constant value tracking.
@@ -99,7 +100,9 @@ module Rigor
99
100
  # references resolve correctly. Multiple writes to the
100
101
  # same qualified name union via `Type::Combinator.union`.
101
102
  in_source_constants = build_in_source_constants(root, seeded_scope)
102
- seeded_scope = seeded_scope.with_in_source_constants(in_source_constants)
103
+ seeded_scope = seeded_scope.with_discovery(
104
+ seeded_scope.discovery.with(in_source_constants: in_source_constants)
105
+ )
103
106
 
104
107
  # Slice 7 phase 12. In-source method discovery. Walks
105
108
  # every class/module body for `Prism::DefNode` and
@@ -115,7 +118,7 @@ module Rigor
115
118
  discovered_methods = deep_merge_class_methods(
116
119
  default_scope.discovered_methods, build_discovered_methods(root)
117
120
  )
118
- seeded_scope = seeded_scope.with_discovered_methods(discovered_methods)
121
+ seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(discovered_methods: discovered_methods))
119
122
 
120
123
  # v0.0.2 #5 + ADR-24 slice 2 — record per-instance-method
121
124
  # def nodes, the class -> superclass map, and the
@@ -179,12 +182,15 @@ module Rigor
179
182
  build_data_member_layouts(root)
180
183
  )
181
184
 
182
- seeded_scope
183
- .with_discovered_def_nodes(def_nodes)
184
- .with_discovered_superclasses(superclasses)
185
- .with_discovered_includes(includes)
186
- .with_discovered_method_visibilities(method_visibilities)
187
- .with_data_member_layouts(data_member_layouts)
185
+ seeded_scope.with_discovery(
186
+ seeded_scope.discovery.with(
187
+ discovered_def_nodes: def_nodes,
188
+ discovered_superclasses: superclasses,
189
+ discovered_includes: includes,
190
+ discovered_method_visibilities: method_visibilities,
191
+ data_member_layouts: data_member_layouts
192
+ )
193
+ )
188
194
  end
189
195
 
190
196
  # Slice 7 phase 2. Builds the class-level ivar accumulator
@@ -328,7 +334,7 @@ module Rigor
328
334
  end
329
335
  end
330
336
 
331
- def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars,
337
+ def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/CyclomaticComplexity
332
338
  read_before_write = nil, init_writes = nil)
333
339
  return unless node.is_a?(Prism::Node)
334
340
 
@@ -363,6 +369,13 @@ module Rigor
363
369
  collect_def_ivar_writes(node, qualified_prefix, default_scope, accumulator,
364
370
  mutated_ivars, read_before_write, init_writes)
365
371
  return
372
+ when Prism::CallNode
373
+ if init_writes && !qualified_prefix.empty? &&
374
+ node.block.is_a?(Prism::BlockNode) &&
375
+ block_initializer?(qualified_prefix.join("::"), node.name, default_scope)
376
+ collect_block_ivar_writes(node.block, qualified_prefix, default_scope,
377
+ accumulator, mutated_ivars, init_writes)
378
+ end
366
379
  end
367
380
 
368
381
  node.compact_child_nodes.each do |child|
@@ -399,6 +412,53 @@ module Rigor
399
412
  collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes, default_scope)
400
413
  end
401
414
 
415
+ # ADR-38 block-form: collects ivar writes from a CallNode's
416
+ # block body (e.g. RSpec `before { @x = … }` / `let(:x) { … }`)
417
+ # and folds them into `init_writes`, suppressing the
418
+ # read-before-write nil contribution the same way a def-form
419
+ # initializer does. The block body is always treated as an
420
+ # initializer (the caller has already verified the method name
421
+ # is declared as a block_method initializer), so there is no
422
+ # read-before-write evidence collection step here.
423
+ def collect_block_ivar_writes(block_node, qualified_prefix, default_scope, accumulator,
424
+ mutated_ivars, init_writes)
425
+ return if block_node.body.nil? || qualified_prefix.empty?
426
+
427
+ class_name = qualified_prefix.join("::")
428
+ self_type = Type::Combinator.nominal_of(class_name)
429
+ body_scope = default_scope.with_self_type(self_type)
430
+
431
+ gather_ivar_writes(block_node.body, body_scope, class_name, accumulator,
432
+ EMPTY_GUARDED_IVARS, mutated_ivars)
433
+
434
+ seen_writes = Set.new
435
+ read_first = Set.new
436
+ detect_read_before_write(block_node.body, seen_writes, read_first)
437
+ init_set = (init_writes[class_name] ||= Set.new)
438
+ seen_writes.each { |name| init_set << name }
439
+ end
440
+
441
+ # ADR-38 block-form gate: true when a loaded plugin declares
442
+ # `method_name` a block-form initializer for `class_name` (or
443
+ # an ancestor). Mirrors `additional_initializer?` but queries
444
+ # `covers_block_method?` instead of `covers_method?`.
445
+ def block_initializer?(class_name, method_name, default_scope)
446
+ return false if class_name.nil? || default_scope.nil?
447
+
448
+ environment = default_scope.environment
449
+ registry = environment&.plugin_registry
450
+ return false if registry.nil?
451
+ return false if registry.respond_to?(:empty?) && registry.empty?
452
+ return false unless registry.respond_to?(:additional_initializers)
453
+
454
+ registry.additional_initializers.any? do |entry|
455
+ entry.covers_block_method?(method_name) &&
456
+ class_matches_constraint?(class_name, entry.receiver_constraint, environment)
457
+ end
458
+ rescue StandardError
459
+ false
460
+ end
461
+
402
462
  # Walks the method body in AST (== execution) order
403
463
  # tracking ivar names whose first reference is a read.
404
464
  # The set is unioned into the class-wide
@@ -476,31 +476,19 @@ module Rigor
476
476
  # carriers like `Nominal[Integer]` (Integer is always truthy
477
477
  # in Ruby — including 0) also collapse the dead else.
478
478
  def live_branch_for_if(node, pred_type, post_pred)
479
- case predicate_certainty(pred_type)
480
- when :always_truthy then eval_branch_or_nil(node.statements, post_pred)
481
- when :always_falsey then eval_branch_or_nil(node.subsequent, post_pred)
479
+ case Narrowing.predicate_certainty(pred_type)
480
+ when :truthy then eval_branch_or_nil(node.statements, post_pred)
481
+ when :falsey then eval_branch_or_nil(node.subsequent, post_pred)
482
482
  end
483
483
  end
484
484
 
485
485
  def live_branch_for_unless(node, pred_type, post_pred)
486
- case predicate_certainty(pred_type)
487
- when :always_truthy then eval_branch_or_nil(node.else_clause, post_pred)
488
- when :always_falsey then eval_branch_or_nil(node.statements, post_pred)
486
+ case Narrowing.predicate_certainty(pred_type)
487
+ when :truthy then eval_branch_or_nil(node.else_clause, post_pred)
488
+ when :falsey then eval_branch_or_nil(node.statements, post_pred)
489
489
  end
490
490
  end
491
491
 
492
- def predicate_certainty(pred_type)
493
- return nil if pred_type.nil? || pred_type.is_a?(Type::Bot)
494
-
495
- truthy_bot = Narrowing.narrow_truthy(pred_type).is_a?(Type::Bot)
496
- falsey_bot = Narrowing.narrow_falsey(pred_type).is_a?(Type::Bot)
497
-
498
- return :always_falsey if truthy_bot && !falsey_bot
499
- return :always_truthy if !truthy_bot && falsey_bot
500
-
501
- nil
502
- end
503
-
504
492
  def eval_else(node)
505
493
  return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
506
494
 
@@ -1421,10 +1409,9 @@ module Rigor
1421
1409
  end
1422
1410
  end
1423
1411
 
1424
- # ADR-37 slice 2 — gathers each plugin's post-return narrowing from
1425
- # BOTH the narrow `type_specifier` DSL (method-gated, wrapped as a
1426
- # facts-only `FlowContribution`) and the legacy
1427
- # `flow_contribution_for` escape valve, swallowing per-plugin
1412
+ # ADR-37 slice 2 / ADR-52 WD3 — gathers each plugin's post-return
1413
+ # narrowing from the method-gated `type_specifier` DSL, wrapped as
1414
+ # a facts-only `FlowContribution`, swallowing per-plugin
1428
1415
  # exceptions so a buggy plugin can't abort the assertion path.
1429
1416
  EMPTY_CONTRIBUTIONS = [].freeze
1430
1417
  private_constant :EMPTY_CONTRIBUTIONS
@@ -1432,8 +1419,11 @@ module Rigor
1432
1419
  # Per-dispatch collection of plugin narrowing contributions. Mirrors
1433
1420
  # `MethodDispatcher#collect_plugin_contributions`: visit only the
1434
1421
  # registry-ordered subset of plugins that implement a per-call path
1435
- # (`for_statement` = overrides `flow_contribution_for` or declares a
1436
- # `type_specifier`), gate each path by membership, and accumulate
1422
+ # (`for_statement` = declares a `type_specifier`), gate each path
1423
+ # by membership AND by the ADR-52 WD1 method-name gates (every
1424
+ # `type_specifier` rule is `methods:`-gated, so the common
1425
+ # no-candidate case is a single Set probe; a pruned
1426
+ # consultation could only have returned `[]`), and accumulate
1437
1427
  # lazily (shared frozen empty array otherwise). Same contributions in
1438
1428
  # the same order as visiting every plugin; the caller is read-only.
1439
1429
  def collect_plugin_contributions(registry, call_node, current_scope)
@@ -1441,16 +1431,21 @@ module Rigor
1441
1431
  relevant = index.for_statement
1442
1432
  return EMPTY_CONTRIBUTIONS if relevant.empty?
1443
1433
 
1434
+ name = call_node.respond_to?(:name) ? call_node.name : nil
1435
+ return EMPTY_CONTRIBUTIONS unless index.statement_candidate?(name)
1436
+
1437
+ collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
1438
+ end
1439
+
1440
+ # The post-gate walk, in registry order — the same order the
1441
+ # ungated walk used.
1442
+ def collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
1444
1443
  result = nil
1445
1444
  relevant.each do |plugin|
1446
- if index.flow?(plugin)
1447
- legacy = plugin.flow_contribution_for(call_node: call_node, scope: current_scope)
1448
- (result ||= []) << legacy if legacy.is_a?(Rigor::FlowContribution)
1449
- end
1450
- if index.type_specifier?(plugin)
1451
- facts = plugin.type_specifier_facts(call_node: call_node, scope: current_scope)
1452
- (result ||= []) << Rigor::FlowContribution.new(post_return_facts: facts) if facts && !facts.empty?
1453
- end
1445
+ next unless index.type_specifier_candidate_for?(plugin, name)
1446
+
1447
+ facts = plugin.type_specifier_facts(call_node: call_node, scope: current_scope)
1448
+ (result ||= []) << Rigor::FlowContribution.new(post_return_facts: facts) if facts && !facts.empty?
1454
1449
  rescue StandardError
1455
1450
  next
1456
1451
  end
@@ -1834,30 +1829,18 @@ module Rigor
1834
1829
  # ScopeIndexer-populated declaration overrides
1835
1830
  # (`Prism::ConstantReadNode` for `module Foo` headers, etc.)
1836
1831
  # remain reachable from inside nested bodies.
1837
- def build_fresh_body_scope # rubocop:disable Metrics/AbcSize
1838
- # Single allocation instead of a 13-deep `with_*` chain — this runs
1839
- # per class/method body on the main walk, so the chain's dozen
1840
- # throwaway intermediate Scopes were a top `Scope#rebuild` source.
1841
- # Local-empty by design; every field is a plain inherited reference
1842
- # and the unset fields default to the same empty bindings the chain
1843
- # (from `Scope.empty`) left them at, so the scope is identical (ADR-44).
1832
+ def build_fresh_body_scope
1833
+ # Single allocation instead of a deep `with_*` chain — this runs
1834
+ # per class/method body on the main walk, so the chain's throwaway
1835
+ # intermediate Scopes were a top `Scope#rebuild` source (ADR-44).
1836
+ # Local-empty by design; the discovery index is inherited whole by
1837
+ # reference (ADR-53 Track A), so a table added to the index can no
1838
+ # longer be dropped here by a missed per-field copy.
1844
1839
  Scope.new(
1845
1840
  environment: scope.environment,
1846
1841
  locals: {}.freeze,
1847
1842
  source_path: scope.source_path,
1848
- declared_types: scope.declared_types,
1849
- discovered_classes: scope.discovered_classes,
1850
- in_source_constants: scope.in_source_constants,
1851
- class_ivars: scope.class_ivars,
1852
- class_cvars: scope.class_cvars,
1853
- program_globals: scope.program_globals,
1854
- discovered_methods: scope.discovered_methods,
1855
- discovered_def_nodes: scope.discovered_def_nodes,
1856
- discovered_def_sources: scope.discovered_def_sources,
1857
- discovered_superclasses: scope.discovered_superclasses,
1858
- discovered_includes: scope.discovered_includes,
1859
- discovered_class_sources: scope.discovered_class_sources,
1860
- discovered_method_visibilities: scope.discovered_method_visibilities
1843
+ discovery: scope.discovery
1861
1844
  )
1862
1845
  end
1863
1846