rigortype 0.1.7 → 0.1.8

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.
@@ -1025,11 +1025,15 @@ module Rigor
1025
1025
  local_def = node.receiver.nil? ? scope.top_level_def_for(node.name) : nil
1026
1026
  if local_def
1027
1027
  local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
1028
- return local_inference if local_inference
1029
-
1030
- # The local def matches by name but the
1031
- # parameter shape is too complex for the first-
1032
- # iteration binder (kwargs / optionals / rest).
1028
+ return local_inference if local_inference && adoptable_self_call_result?(local_inference)
1029
+
1030
+ # The local def matches by name but the inference
1031
+ # was disqualified either the parameter shape is
1032
+ # too complex for the first-iteration binder
1033
+ # (kwargs / optionals / rest), or ADR-24 slice 1's
1034
+ # conservative gate declined the resolved return
1035
+ # type inside a class body (see
1036
+ # `adoptable_self_call_result?`).
1033
1037
  # Returning `Dynamic[Top]` is the safest answer:
1034
1038
  # we know RBS dispatch would be wrong (the
1035
1039
  # method is user-defined and shadows whatever
@@ -1069,7 +1073,11 @@ module Rigor
1069
1073
  # the body with the call's argument types bound and
1070
1074
  # return the body's last-expression type.
1071
1075
  user_inference = try_user_method_inference(receiver, node, arg_types)
1072
- return user_inference if user_inference
1076
+ if user_inference
1077
+ return user_inference if adoptable_self_call_result?(user_inference)
1078
+
1079
+ return dynamic_top
1080
+ end
1073
1081
 
1074
1082
  # Dynamic-origin propagation: when the receiver is Dynamic[T] and
1075
1083
  # no positive rule resolves the call, the result inherits the
@@ -1112,10 +1120,39 @@ module Rigor
1112
1120
  nil
1113
1121
  end
1114
1122
 
1123
+ # ADR-24 slice 1 — implicit-self method-call resolution.
1124
+ # `discovered_def_nodes` is now carried into method /
1125
+ # class body scopes (see `StatementEvaluator#build_fresh_body_scope`),
1126
+ # so a call written with no explicit receiver inside a
1127
+ # method body resolves against the enclosing class's own
1128
+ # definitions and the file's top-level defs. Before
1129
+ # slice 1 every such call typed `Dynamic[top]`.
1130
+ #
1131
+ # The adoption of the resolved return type is gated:
1132
+ #
1133
+ # - At top-level / inside a DSL block (`scope.self_type`
1134
+ # is nil) the result is adopted unchanged — this is
1135
+ # the pre-slice-1 surface (the v0.0.3 A local-`def`
1136
+ # shortcut) and MUST keep working.
1137
+ # - Inside a class body / method body (`self_type` set)
1138
+ # the result is adopted ONLY when it is `Bot`. A `Bot`
1139
+ # return is an always-diverging guard helper; adopting
1140
+ # it can only ever enable correct terminating-branch
1141
+ # narrowing, never a new `undefined-method` /
1142
+ # argument-type false positive. A non-`Bot` resolved
1143
+ # return is kept as `Dynamic[top]` (WD3) — adopting
1144
+ # precise non-`Bot` returns project-wide awaits the
1145
+ # callee-return-inference precision a later slice
1146
+ # brings (measured: unconditional adoption regressed
1147
+ # `rigor check lib` by 16 diagnostics).
1148
+ def adoptable_self_call_result?(type)
1149
+ scope.self_type.nil? || type.is_a?(Type::Bot)
1150
+ end
1151
+
1115
1152
  def try_user_method_inference(receiver, call_node, arg_types)
1116
1153
  return nil unless receiver.is_a?(Type::Nominal)
1117
1154
 
1118
- def_node = scope.user_def_for(receiver.class_name, call_node.name)
1155
+ def_node = resolve_user_def_through_ancestors(receiver.class_name, call_node.name)
1119
1156
  return nil if def_node.nil?
1120
1157
 
1121
1158
  infer_user_method_return(def_node, receiver, arg_types)
@@ -1123,6 +1160,81 @@ module Rigor
1123
1160
  nil
1124
1161
  end
1125
1162
 
1163
+ # ADR-24 slice 2 — resolves `method_name` against
1164
+ # `class_name`'s own `def`s, then walks the user-class
1165
+ # ancestor chain: included / prepended modules (transitive)
1166
+ # and the superclass chain. RBS-known ancestors are NOT
1167
+ # walked here — the `MethodDispatcher` RBS tier runs before
1168
+ # `try_user_method_inference` and already covers them; an
1169
+ # ancestor name that resolves to no project-discovered
1170
+ # class/module ends that branch. Cross-file: the chain is
1171
+ # followed through `Scope#discovered_superclasses` /
1172
+ # `#discovered_includes` / `#discovered_def_nodes`, which
1173
+ # the runner seeds from the project-wide pre-pass. The walk
1174
+ # is breadth-first, cycle-guarded, and node-count-capped.
1175
+ ANCESTOR_WALK_LIMIT = 100
1176
+ private_constant :ANCESTOR_WALK_LIMIT
1177
+
1178
+ def resolve_user_def_through_ancestors(class_name, method_name)
1179
+ queue = [class_name.to_s]
1180
+ seen = {}
1181
+ visited = 0
1182
+ until queue.empty?
1183
+ current = queue.shift
1184
+ next if current.nil? || seen[current]
1185
+
1186
+ seen[current] = true
1187
+ visited += 1
1188
+ return nil if visited > ANCESTOR_WALK_LIMIT
1189
+
1190
+ found = scope.user_def_for(current, method_name)
1191
+ return found if found
1192
+
1193
+ enqueue_ancestors(current, queue)
1194
+ end
1195
+ nil
1196
+ end
1197
+
1198
+ # Pushes `current`'s direct ancestors onto the BFS queue:
1199
+ # included / prepended modules first (Ruby places mixins
1200
+ # nearer than the superclass), then the superclass. Each
1201
+ # as-written name is resolved against `current`'s lexical
1202
+ # nesting; names that resolve to no project class/module
1203
+ # are dropped (RBS-known / third-party ancestors).
1204
+ def enqueue_ancestors(current, queue)
1205
+ scope.includes_of(current).each do |raw|
1206
+ resolved = resolve_ancestor_class_name(current, raw)
1207
+ queue.push(resolved) if resolved
1208
+ end
1209
+ raw_super = scope.superclass_of(current)
1210
+ return if raw_super.nil?
1211
+
1212
+ resolved_super = resolve_ancestor_class_name(current, raw_super)
1213
+ queue.push(resolved_super) if resolved_super
1214
+ end
1215
+
1216
+ # Resolves a superclass name AS WRITTEN (`"Base"`, or a
1217
+ # qualified `"A::B"`) to a project-discovered class,
1218
+ # following Ruby's `Module.nesting` constant lookup: try
1219
+ # the raw name under each enclosing namespace of the
1220
+ # subclass, innermost first, then bare. Returns nil when
1221
+ # no candidate names a discovered user class (e.g. the
1222
+ # superclass is an RBS-known or third-party class).
1223
+ def resolve_ancestor_class_name(subclass_qualified, raw_superclass)
1224
+ segments = subclass_qualified.split("::")
1225
+ (segments.length - 1).downto(0) do |i|
1226
+ candidate = (segments[0, i] + [raw_superclass]).join("::")
1227
+ return candidate if known_user_class?(candidate)
1228
+ end
1229
+ nil
1230
+ end
1231
+
1232
+ def known_user_class?(name)
1233
+ scope.discovered_superclasses.key?(name) ||
1234
+ scope.discovered_def_nodes.key?(name) ||
1235
+ scope.discovered_includes.key?(name)
1236
+ end
1237
+
1126
1238
  INFERENCE_GUARD_KEY = :__rigor_user_method_inference_stack__
1127
1239
  private_constant :INFERENCE_GUARD_KEY
1128
1240
 
@@ -1132,11 +1244,20 @@ module Rigor
1132
1244
  body_scope = build_user_method_body_scope(def_node, receiver, arg_types)
1133
1245
  return nil if body_scope.nil?
1134
1246
 
1135
- # Recursion-guard signature. Uses `describe(:short)`
1136
- # so non-Nominal receivers (e.g. the implicit
1137
- # `Object` carrier used for top-level / DSL-block
1138
- # defs in v0.0.3 A) can participate without raising.
1139
- signature = [receiver.describe(:short), def_node.name, arg_types.map { |t| t.describe(:short) }]
1247
+ # Recursion-guard signature. Keyed on `(receiver,
1248
+ # method)` only NOT the argument types. ADR-24 WD5:
1249
+ # a method whose summary is still being computed
1250
+ # resolves to `Dynamic[top]` for that cycle. Keying on
1251
+ # arg types would let mutual recursion through a
1252
+ # `module_function` module (`Acceptance#accepts` →
1253
+ # `accepts_one` → `accepts_dynamic` → `accepts`)
1254
+ # recurse unboundedly whenever the carried argument
1255
+ # types differ at each level — observed as a
1256
+ # `SystemStackError` once implicit-self calls began
1257
+ # resolving during the main walk. `describe(:short)`
1258
+ # keeps non-Nominal receivers (the implicit `Object`
1259
+ # carrier for top-level / DSL-block defs) printable.
1260
+ signature = [receiver.describe(:short), def_node.name]
1140
1261
  stack = (Thread.current[INFERENCE_GUARD_KEY] ||= [])
1141
1262
  return Type::Combinator.untyped if stack.include?(signature)
1142
1263
 
@@ -1174,6 +1295,8 @@ module Rigor
1174
1295
  .with_program_globals(scope.program_globals)
1175
1296
  .with_discovered_methods(scope.discovered_methods)
1176
1297
  .with_discovered_def_nodes(scope.discovered_def_nodes)
1298
+ .with_discovered_superclasses(scope.discovered_superclasses)
1299
+ .with_discovered_includes(scope.discovered_includes)
1177
1300
  .with_self_type(receiver)
1178
1301
 
1179
1302
  required.each_with_index do |param, index|
@@ -85,10 +85,17 @@ module Rigor
85
85
  # `Bundler::URI::Generic` per `Kernel#dup: () -> self`
86
86
  # rather than `Object`. Defaults to nil (compute self
87
87
  # from the resolved class_name as before).
88
+ # @param public_only [Boolean] when true, a method whose RBS
89
+ # accessibility is `:private` does not resolve (the call
90
+ # yields `nil`, i.e. "no rule"). Set by the explicit-
91
+ # non-`self`-receiver user-class fallback so a call like
92
+ # `Favourite.select(...)` does not adopt the private
93
+ # `Kernel#select` signature.
88
94
  # @return [Rigor::Type, nil] inferred return type, or `nil`
89
95
  # when no rule resolves (no class name, no method, dispatch
90
96
  # on a Top/Dynamic[Top] receiver, etc.).
91
- def try_dispatch(receiver:, method_name:, args:, environment:, block_type: nil, self_type_override: nil)
97
+ def try_dispatch(receiver:, method_name:, args:, environment:, block_type: nil, self_type_override: nil,
98
+ public_only: false)
92
99
  return nil if environment.nil?
93
100
  return nil unless environment.rbs_loader
94
101
 
@@ -98,7 +105,8 @@ module Rigor
98
105
  args: args,
99
106
  environment: environment,
100
107
  block_type: block_type,
101
- self_type_override: self_type_override
108
+ self_type_override: self_type_override,
109
+ public_only: public_only
102
110
  )
103
111
  end
104
112
 
@@ -140,32 +148,39 @@ module Rigor
140
148
  class << self
141
149
  private
142
150
 
143
- def dispatch_for(receiver:, method_name:, args:, environment:, block_type:, self_type_override: nil)
151
+ def dispatch_for(receiver:, method_name:, args:, environment:, block_type:, self_type_override: nil,
152
+ public_only: false)
144
153
  args ||= []
145
154
  case receiver
146
155
  when Type::Union
147
- dispatch_union(receiver, method_name, args, environment, block_type, self_type_override)
156
+ dispatch_union(receiver, method_name, args, environment, block_type, self_type_override,
157
+ public_only: public_only)
148
158
  else
149
- dispatch_one(receiver, method_name, args, environment, block_type, self_type_override)
159
+ dispatch_one(receiver, method_name, args, environment, block_type, self_type_override,
160
+ public_only: public_only)
150
161
  end
151
162
  end
152
163
 
153
- def dispatch_union(receiver, method_name, args, environment, block_type, self_type_override = nil)
164
+ def dispatch_union(receiver, method_name, args, environment, block_type, self_type_override = nil,
165
+ public_only: false)
154
166
  results = receiver.members.map do |member|
155
- dispatch_one(member, method_name, args, environment, block_type, self_type_override)
167
+ dispatch_one(member, method_name, args, environment, block_type, self_type_override,
168
+ public_only: public_only)
156
169
  end
157
170
  return nil if results.any?(&:nil?)
158
171
 
159
172
  Type::Combinator.union(*results)
160
173
  end
161
174
 
162
- def dispatch_one(receiver, method_name, args, environment, block_type, self_type_override = nil)
175
+ def dispatch_one(receiver, method_name, args, environment, block_type, self_type_override = nil,
176
+ public_only: false)
163
177
  descriptor = receiver_descriptor(receiver)
164
178
  return nil unless descriptor
165
179
 
166
180
  class_name, kind, receiver_args = descriptor
167
181
  method_definition = lookup_method(environment, class_name, kind, method_name)
168
182
  return nil unless method_definition
183
+ return nil if public_only && method_private?(method_definition)
169
184
 
170
185
  type_vars = build_type_vars(environment, class_name, receiver_args)
171
186
  translate_return_type(
@@ -242,6 +257,16 @@ module Rigor
242
257
  ]
243
258
  end
244
259
 
260
+ # True when the RBS method definition is `private`. A call
261
+ # with an explicit, non-`self` receiver cannot reach a
262
+ # private method (Ruby raises `NoMethodError`), so the
263
+ # explicit-receiver user-class fallback uses this to reject
264
+ # private signatures rather than return a wrong type.
265
+ def method_private?(method_definition)
266
+ method_definition.respond_to?(:accessibility) &&
267
+ method_definition.accessibility == :private
268
+ end
269
+
245
270
  def lookup_method(environment, class_name, kind, method_name)
246
271
  case kind
247
272
  when :instance
@@ -191,7 +191,7 @@ module Rigor
191
191
  # introspection (`attr_reader`, `private`, ...) on
192
192
  # user classes without requiring the user to author
193
193
  # their own RBS.
194
- try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type)
194
+ try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type, call_node)
195
195
  end
196
196
 
197
197
  # v0.1.3 — discovered-method dispatch tier. `scope` carries
@@ -651,7 +651,7 @@ module Rigor
651
651
  )
652
652
  end
653
653
 
654
- def try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type)
654
+ def try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type, call_node = nil)
655
655
  return nil if environment.nil?
656
656
 
657
657
  fallback_receiver = user_class_fallback_receiver(receiver_type, environment)
@@ -665,16 +665,44 @@ module Rigor
665
665
  # `Bundler::URI::Generic` instance method types `base`
666
666
  # as `Object` because `Bundler::URI::Generic` is not in
667
667
  # RBS and the fallback's `self` resolves to Object.
668
+ #
669
+ # `public_only:` — when the call has an EXPLICIT, non-`self`
670
+ # receiver (`Favourite.select(...)`), suppress the private
671
+ # `Object`/`Kernel`/`Class` methods the fallback would
672
+ # otherwise resolve. Ruby raises `NoMethodError` for a
673
+ # private method called with an explicit receiver, so
674
+ # resolving `Favourite.select` to the private `Kernel#select`
675
+ # (`-> Array[String]`) is a confidently-wrong type. Implicit-
676
+ # self / `self.`-receiver calls (`puts`, `raise`, `require`)
677
+ # keep resolving — those are the fallback's intended targets.
668
678
  RbsDispatch.try_dispatch(
669
679
  receiver: fallback_receiver,
670
680
  method_name: method_name,
671
681
  args: arg_types,
672
682
  environment: environment,
673
683
  block_type: block_type,
674
- self_type_override: receiver_type
684
+ self_type_override: receiver_type,
685
+ public_only: explicit_non_self_receiver?(call_node)
675
686
  )
676
687
  end
677
688
 
689
+ # True when the call node carries an explicit receiver that is
690
+ # not the literal `self`. Such a call cannot legally dispatch to
691
+ # a private method, so the user-class fallback must skip private
692
+ # signatures rather than return a confidently-wrong type. Returns
693
+ # false for implicit-self calls and `self.`-receiver calls (both
694
+ # may legally reach a private method in modern Ruby), and false
695
+ # when no `call_node` is supplied (internal dispatcher callers).
696
+ def explicit_non_self_receiver?(call_node)
697
+ return false if call_node.nil?
698
+ return false unless call_node.respond_to?(:receiver)
699
+
700
+ receiver = call_node.receiver
701
+ return false if receiver.nil?
702
+
703
+ !receiver.is_a?(Prism::SelfNode)
704
+ end
705
+
678
706
  def user_class_fallback_receiver(receiver_type, environment)
679
707
  case receiver_type
680
708
  when Type::Nominal
@@ -109,12 +109,11 @@ module Rigor
109
109
  discovered_methods = build_discovered_methods(root)
110
110
  seeded_scope = seeded_scope.with_discovered_methods(discovered_methods)
111
111
 
112
- # v0.0.2 #5 also record the def node itself for
113
- # instance methods so the engine can re-type the body
114
- # when a call site dispatches against a user-defined
115
- # method without an RBS sig.
116
- discovered_def_nodes = build_discovered_def_nodes(root)
117
- seeded_scope = seeded_scope.with_discovered_def_nodes(discovered_def_nodes)
112
+ # v0.0.2 #5 + ADR-24 slice 2 record per-instance-method
113
+ # def nodes, the class -> superclass map, and the
114
+ # class/module -> included-modules map, each merged under
115
+ # the cross-file pre-pass seed (see below).
116
+ seeded_scope = merge_project_method_indexes(seeded_scope, default_scope, root)
118
117
 
119
118
  # v0.1.2 — per-class table of method visibilities
120
119
  # (`:public` / `:private` / `:protected`). The
@@ -134,6 +133,31 @@ module Rigor
134
133
  table
135
134
  end
136
135
 
136
+ # v0.0.2 #5 + ADR-24 slice 2 — seeds the three
137
+ # project-method indexes onto `seeded_scope`: the
138
+ # per-instance-method def-node table, the class ->
139
+ # superclass map, and the class/module -> included-modules
140
+ # map. Each per-file table is merged UNDER the cross-file
141
+ # `discovered_def_index_for_paths` seed carried on
142
+ # `default_scope` — same-file declarations win per entry,
143
+ # the cross-file seed supplies sibling-file ancestors.
144
+ def merge_project_method_indexes(seeded_scope, default_scope, root)
145
+ def_nodes = default_scope.discovered_def_nodes.merge(
146
+ build_discovered_def_nodes(root)
147
+ ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
148
+ superclasses = default_scope.discovered_superclasses.merge(
149
+ build_discovered_superclasses(root)
150
+ )
151
+ includes = default_scope.discovered_includes.merge(
152
+ build_discovered_includes(root)
153
+ ) { |_class, cross_file, per_file| (cross_file + per_file).uniq }
154
+
155
+ seeded_scope
156
+ .with_discovered_def_nodes(def_nodes)
157
+ .with_discovered_superclasses(superclasses)
158
+ .with_discovered_includes(includes)
159
+ end
160
+
137
161
  # Slice 7 phase 2. Builds the class-level ivar accumulator
138
162
  # by walking every `Prism::ClassNode` / `Prism::ModuleNode`
139
163
  # body, descending into each nested `Prism::DefNode`, and
@@ -580,6 +604,94 @@ module Rigor
580
604
  accumulator[class_name][def_node.name] = def_node
581
605
  end
582
606
 
607
+ # ADR-24 slice 2 — per-class table mapping a fully
608
+ # qualified user class to its superclass name AS WRITTEN
609
+ # at the `class Foo < Bar` declaration. Only constant
610
+ # superclasses are recorded (`class Foo < Struct.new(...)`
611
+ # and other non-constant superclasses produce no entry).
612
+ # The as-written name is resolved to a qualified class at
613
+ # the call site against the subclass's lexical nesting —
614
+ # see `ExpressionTyper#resolve_ancestor_class_name`.
615
+ def build_discovered_superclasses(root)
616
+ accumulator = {}
617
+ walk_class_superclasses(root, [], accumulator)
618
+ accumulator.freeze
619
+ end
620
+
621
+ def walk_class_superclasses(node, qualified_prefix, accumulator)
622
+ return unless node.is_a?(Prism::Node)
623
+
624
+ case node
625
+ when Prism::ClassNode
626
+ name = qualified_name_for(node.constant_path)
627
+ if name
628
+ full = (qualified_prefix + [name]).join("::")
629
+ superclass = node.superclass && qualified_name_for(node.superclass)
630
+ accumulator[full] = superclass if superclass
631
+ walk_class_superclasses(node.body, qualified_prefix + [name], accumulator) if node.body
632
+ return
633
+ end
634
+ when Prism::ModuleNode
635
+ name = qualified_name_for(node.constant_path)
636
+ if name
637
+ walk_class_superclasses(node.body, qualified_prefix + [name], accumulator) if node.body
638
+ return
639
+ end
640
+ end
641
+
642
+ node.compact_child_nodes.each do |child|
643
+ walk_class_superclasses(child, qualified_prefix, accumulator)
644
+ end
645
+ end
646
+
647
+ MIXIN_CALL_NAMES = %i[include prepend].freeze
648
+
649
+ # ADR-24 slice 2 — per-class/module table mapping a fully
650
+ # qualified user class or module to the list of module
651
+ # names it `include`s / `prepend`s, AS WRITTEN at the
652
+ # mixin call (`include Foo` / `include Foo::Bar`). Only
653
+ # constant arguments are recorded; dynamic mixins
654
+ # (`include some_method`) produce no entry. `prepend` is
655
+ # bucketed with `include` — both contribute instance
656
+ # methods to the ancestor chain. `extend` is NOT tracked
657
+ # (it adds singleton methods; ADR-24 slice 2 resolves the
658
+ # instance-side chain).
659
+ def build_discovered_includes(root)
660
+ accumulator = {}
661
+ walk_class_includes(root, [], nil, accumulator)
662
+ accumulator.transform_values { |mods| mods.uniq.freeze }.freeze
663
+ end
664
+
665
+ def walk_class_includes(node, qualified_prefix, current_class, accumulator)
666
+ return unless node.is_a?(Prism::Node)
667
+
668
+ case node
669
+ when Prism::ClassNode, Prism::ModuleNode
670
+ name = qualified_name_for(node.constant_path)
671
+ if name
672
+ full = (qualified_prefix + [name]).join("::")
673
+ walk_class_includes(node.body, qualified_prefix + [name], full, accumulator) if node.body
674
+ return
675
+ end
676
+ when Prism::CallNode
677
+ record_mixin_call(node, current_class, accumulator)
678
+ end
679
+
680
+ node.compact_child_nodes.each do |child|
681
+ walk_class_includes(child, qualified_prefix, current_class, accumulator)
682
+ end
683
+ end
684
+
685
+ def record_mixin_call(node, current_class, accumulator)
686
+ return unless current_class && node.receiver.nil?
687
+ return unless MIXIN_CALL_NAMES.include?(node.name)
688
+
689
+ node.arguments&.arguments&.each do |arg|
690
+ mod = qualified_name_for(arg)
691
+ (accumulator[current_class] ||= []) << mod if mod
692
+ end
693
+ end
694
+
583
695
  VISIBILITY_MODIFIERS = %i[public private protected].freeze
584
696
 
585
697
  # v0.1.2 — per-class method-visibility table for the
@@ -845,6 +957,44 @@ module Rigor
845
957
  accumulator.freeze
846
958
  end
847
959
 
960
+ # ADR-24 slice 2 — cross-file companion to
961
+ # `discovered_classes_for_paths`. Walks every project
962
+ # file once and returns both the merged
963
+ # `discovered_def_nodes` table (a class reopened across
964
+ # files has its method tables merged) and the merged
965
+ # class -> superclass-name map. The engine consults these
966
+ # so an implicit-self call inside a subclass resolves
967
+ # against a superclass `def` declared in a sibling file
968
+ # (`Mastodon::CLI::Accounts` calling a helper defined in
969
+ # `Mastodon::CLI::Base`).
970
+ #
971
+ # @param paths [Array<String>] project file paths.
972
+ # @param buffer [Rigor::Analysis::BufferBinding, nil]
973
+ # @return [Hash{Symbol => Hash}] `{ def_nodes:, superclasses: }`
974
+ def discovered_def_index_for_paths(paths, buffer: nil)
975
+ def_nodes = {}
976
+ superclasses = {}
977
+ includes = {}
978
+ paths.each do |path|
979
+ physical = buffer ? buffer.resolve(path) : path
980
+ root = Prism.parse(File.read(physical), filepath: path).value
981
+ build_discovered_def_nodes(root).each do |class_name, methods|
982
+ (def_nodes[class_name] ||= {}).merge!(methods)
983
+ end
984
+ superclasses.merge!(build_discovered_superclasses(root))
985
+ build_discovered_includes(root).each do |class_name, mods|
986
+ includes[class_name] = ((includes[class_name] || []) + mods).uniq
987
+ end
988
+ rescue StandardError
989
+ # Skip files that fail to parse or read; the per-file
990
+ # analyzer surfaces the parse error separately.
991
+ next
992
+ end
993
+ def_nodes.each_value(&:freeze)
994
+ includes.each_value(&:freeze)
995
+ { def_nodes: def_nodes.freeze, superclasses: superclasses.freeze, includes: includes.freeze }
996
+ end
997
+
848
998
  # Class-only variant of `record_declarations` — descends
849
999
  # into nested module bodies (so `module Foo; class Bar`
850
1000
  # registers `Foo::Bar`) but never registers the module
@@ -358,9 +358,9 @@ module Rigor
358
358
  # is the falsey edge of the predicate (subsequent
359
359
  # statements observe the predicate-was-false world).
360
360
  return [Type::Combinator.union(then_type, else_type), falsey_scope] \
361
- if branch_unconditionally_exits?(node.statements) && node.subsequent.nil?
361
+ if branch_terminates?(node.statements, then_type) && node.subsequent.nil?
362
362
  return [Type::Combinator.union(then_type, else_type), truthy_scope] \
363
- if branch_unconditionally_exits?(node.subsequent) && node.statements
363
+ if branch_terminates?(node.subsequent, else_type) && node.statements
364
364
 
365
365
  [
366
366
  Type::Combinator.union(then_type, else_type),
@@ -385,9 +385,9 @@ module Rigor
385
385
  # `if`: when the body unconditionally exits and there
386
386
  # is no else, the post-scope is the truthy edge.
387
387
  return [Type::Combinator.union(then_type, else_type), truthy_scope] \
388
- if branch_unconditionally_exits?(node.statements) && node.else_clause.nil?
388
+ if branch_terminates?(node.statements, then_type) && node.else_clause.nil?
389
389
  return [Type::Combinator.union(then_type, else_type), falsey_scope] \
390
- if branch_unconditionally_exits?(node.else_clause) && node.statements
390
+ if branch_terminates?(node.else_clause, else_type) && node.statements
391
391
 
392
392
  [
393
393
  Type::Combinator.union(then_type, else_type),
@@ -709,24 +709,25 @@ module Rigor
709
709
  # `a` when the RHS is skipped, while `a || b` can only produce
710
710
  # the truthy fragment of `a` when the RHS is skipped.
711
711
  #
712
- # When the RHS unconditionally exits (`raise` / `return` /
713
- # `throw` / `exit` / `abort` / `fail` / `next` / `break`), the
714
- # post-OR / post-AND scope is the LHS-skipped edge alone:
715
- # `a or raise` only survives when `a` was truthy, so subsequent
716
- # statements observe `a` narrowed to its truthy fragment; the
717
- # symmetric `a and raise` survives only when `a` was falsey.
718
- # Same shape as the `eval_if` / `eval_unless` early-return
719
- # narrowing.
712
+ # When the RHS is a terminating branch — it `raise`s /
713
+ # `return`s / `throw`s / `exit`s / `break`s / `next`s, OR its
714
+ # inferred type is `Bot` (ADR-24 WD6: a divergent helper such
715
+ # as `a or fail_with_message(...)`, recognised via
716
+ # `branch_terminates?`) the post-OR / post-AND scope is the
717
+ # LHS-skipped edge alone: `a or raise` only survives when `a`
718
+ # was truthy, so subsequent statements observe `a` narrowed to
719
+ # its truthy fragment; the symmetric `a and raise` survives
720
+ # only when `a` was falsey. Same shape as the `eval_if` /
721
+ # `eval_unless` early-return narrowing.
720
722
  def eval_and_or(node)
721
723
  left_type, left_scope = sub_eval(node.left, scope)
722
724
  truthy_left, falsey_left = Narrowing.predicate_scopes(node.left, left_scope)
723
725
  rhs_entry = node.is_a?(Prism::AndNode) ? truthy_left : falsey_left
724
- if branch_unconditionally_exits?(node.right)
725
- # Walk the RHS for side-effects (on_enter callbacks,
726
- # diagnostic dispatch on the raise / return expression
727
- # itself) but discard its scope: control never reaches
728
- # any statement after `a or raise` via that edge.
729
- sub_eval(node.right, rhs_entry)
726
+ right_type, right_scope = sub_eval(node.right, rhs_entry)
727
+
728
+ if branch_terminates?(node.right, right_type)
729
+ # Control never reaches any statement after `a or raise`
730
+ # via the RHS edge the RHS scope is discarded.
730
731
  surviving_type =
731
732
  if node.is_a?(Prism::AndNode)
732
733
  Narrowing.narrow_falsey(left_type)
@@ -737,7 +738,6 @@ module Rigor
737
738
  return [surviving_type, surviving_scope]
738
739
  end
739
740
 
740
- right_type, right_scope = sub_eval(node.right, rhs_entry)
741
741
  skipped_type =
742
742
  if node.is_a?(Prism::AndNode)
743
743
  Narrowing.narrow_falsey(left_type)
@@ -1484,7 +1484,7 @@ module Rigor
1484
1484
  # ScopeIndexer-populated declaration overrides
1485
1485
  # (`Prism::ConstantReadNode` for `module Foo` headers, etc.)
1486
1486
  # remain reachable from inside nested bodies.
1487
- def build_fresh_body_scope
1487
+ def build_fresh_body_scope # rubocop:disable Metrics/AbcSize
1488
1488
  Scope.empty(environment: scope.environment)
1489
1489
  .with_declared_types(scope.declared_types)
1490
1490
  .with_discovered_classes(scope.discovered_classes)
@@ -1493,6 +1493,9 @@ module Rigor
1493
1493
  .with_class_cvars(scope.class_cvars)
1494
1494
  .with_program_globals(scope.program_globals)
1495
1495
  .with_discovered_methods(scope.discovered_methods)
1496
+ .with_discovered_def_nodes(scope.discovered_def_nodes)
1497
+ .with_discovered_superclasses(scope.discovered_superclasses)
1498
+ .with_discovered_includes(scope.discovered_includes)
1496
1499
  .with_discovered_method_visibilities(scope.discovered_method_visibilities)
1497
1500
  end
1498
1501
 
@@ -1692,6 +1695,23 @@ module Rigor
1692
1695
  end
1693
1696
  end
1694
1697
 
1698
+ # ADR-24 WD6 / slice 3 — generalised terminating-branch
1699
+ # detection. `branch_unconditionally_exits?` recognises a
1700
+ # branch SYNTACTICALLY (return / next / break / a call
1701
+ # named raise / throw / exit / abort / fail). A branch
1702
+ # whose *inferred type is `Bot`* also terminates — it
1703
+ # cannot produce a value, so control never falls through
1704
+ # it — regardless of how it is spelled. The canonical
1705
+ # case is a resolved guard helper (`fail_with_message(...)`)
1706
+ # whose body always raises: ADR-24 slice 1 types the call
1707
+ # `bot`, and this OR-test makes `helper(...) if x.nil?`
1708
+ # narrow exactly like `raise ... if x.nil?`. The branch
1709
+ # type is already computed by `eval_if` / `eval_unless`.
1710
+ def branch_terminates?(branch_node, branch_type)
1711
+ branch_unconditionally_exits?(branch_node) ||
1712
+ branch_type.is_a?(Type::Bot)
1713
+ end
1714
+
1695
1715
  def eval_branch_or_nil(branch_node, branch_scope)
1696
1716
  return [Type::Combinator.constant_of(nil), branch_scope] if branch_node.nil?
1697
1717