rigortype 0.1.6 → 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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -29
  3. data/lib/rigor/analysis/baseline.rb +347 -0
  4. data/lib/rigor/analysis/check_rules.rb +60 -3
  5. data/lib/rigor/analysis/diagnostic.rb +17 -3
  6. data/lib/rigor/analysis/runner.rb +178 -3
  7. data/lib/rigor/analysis/worker_session.rb +14 -3
  8. data/lib/rigor/builtins/static_return_refinements.rb +23 -1
  9. data/lib/rigor/cli/baseline_command.rb +377 -0
  10. data/lib/rigor/cli/triage_command.rb +83 -0
  11. data/lib/rigor/cli/triage_renderer.rb +77 -0
  12. data/lib/rigor/cli.rb +78 -3
  13. data/lib/rigor/configuration.rb +21 -1
  14. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  15. data/lib/rigor/environment/rbs_loader.rb +22 -0
  16. data/lib/rigor/environment.rb +13 -0
  17. data/lib/rigor/flow_contribution/fact.rb +20 -10
  18. data/lib/rigor/inference/expression_typer.rb +152 -14
  19. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +57 -11
  20. data/lib/rigor/inference/method_dispatcher.rb +50 -5
  21. data/lib/rigor/inference/narrowing.rb +103 -1
  22. data/lib/rigor/inference/scope_indexer.rb +209 -13
  23. data/lib/rigor/inference/statement_evaluator.rb +91 -10
  24. data/lib/rigor/plugin/macro/heredoc_template.rb +2 -2
  25. data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
  26. data/lib/rigor/scope.rb +46 -0
  27. data/lib/rigor/triage/catalogue.rb +296 -0
  28. data/lib/rigor/triage/hint.rb +27 -0
  29. data/lib/rigor/triage.rb +89 -0
  30. data/lib/rigor/version.rb +1 -1
  31. data/sig/rigor/environment.rbs +2 -0
  32. data/sig/rigor/inference.rbs +1 -0
  33. data/sig/rigor/scope.rbs +6 -0
  34. data/sig/rigor.rbs +1 -0
  35. metadata +8 -1
@@ -59,6 +59,14 @@ module Rigor
59
59
  # the dispatcher tier consuming the registry lands in
60
60
  # slice 2.
61
61
  "pre_eval" => [],
62
+ # ADR-22 — baseline file path. nil (default) means no
63
+ # baseline is loaded; the `false` literal is treated as
64
+ # the explicit-disable form for `.rigor.yml`-side override
65
+ # of an upstream `.rigor.dist.yml` `baseline:` declaration.
66
+ # The presence of `.rigor-baseline.yml` on disk alone does
67
+ # NOT activate filtering — the path must be named here
68
+ # (WD2 (b) of ADR-22).
69
+ "baseline" => nil,
62
70
  "fold_platform_specific_paths" => false,
63
71
  "cache" => {
64
72
  "path" => ".rigor/cache"
@@ -166,7 +174,7 @@ module Rigor
166
174
  :dependencies, :parallel_workers,
167
175
  :bundler_bundle_path, :bundler_auto_detect, :bundler_lockfile,
168
176
  :rbs_collection_lockfile, :rbs_collection_auto_detect,
169
- :pre_eval
177
+ :pre_eval, :baseline_path
170
178
 
171
179
  # Loads a configuration file.
172
180
  #
@@ -321,6 +329,7 @@ module Rigor
321
329
  @pre_eval = expand_pre_eval_entries(
322
330
  Array(data.fetch("pre_eval", DEFAULTS.fetch("pre_eval"))).map(&:to_s)
323
331
  )
332
+ @baseline_path = coerce_baseline_path(data.fetch("baseline", DEFAULTS.fetch("baseline")))
324
333
  @fold_platform_specific_paths = data.fetch(
325
334
  "fold_platform_specific_paths", DEFAULTS.fetch("fold_platform_specific_paths")
326
335
  ) == true
@@ -488,6 +497,17 @@ module Rigor
488
497
  raise ArgumentError, "parallel.workers must be a non-negative Integer, got #{value.inspect} (#{e.message})"
489
498
  end
490
499
 
500
+ # ADR-22 WD2 (b) — `baseline: <path>` activates the file;
501
+ # `baseline: false` is the explicit-disable form (useful in
502
+ # `.rigor.yml` to override an upstream `.rigor.dist.yml`
503
+ # that names a baseline). `nil` (default / absent key) is
504
+ # also "no baseline".
505
+ def coerce_baseline_path(value)
506
+ return nil if value.nil? || value == false
507
+
508
+ value.to_s
509
+ end
510
+
491
511
  def coerce_network_policy(value)
492
512
  sym = value.to_sym
493
513
  unless VALID_NETWORK_POLICIES.include?(sym)
@@ -42,7 +42,7 @@ module Rigor
42
42
  # enough that hard-coding is acceptable; a directory walk
43
43
  # at every call would add stat-cost to no benefit.)
44
44
  VENDORED_GEM_NAMES = Set[
45
- "bcrypt", "idn-ruby", "mysql2", "nokogiri", "pg", "prism", "redis"
45
+ "bcrypt", "bundler", "cgi", "did_you_mean", "idn-ruby", "mysql2", "nokogiri", "pg", "prism", "redis", "rubygems"
46
46
  ].freeze
47
47
 
48
48
  # @param locked_gems [Hash{String => LockfileResolver::LockedGem}]
@@ -160,6 +160,28 @@ module Rigor
160
160
  end
161
161
  end
162
162
 
163
+ # Returns true when the named RBS declaration is a Module
164
+ # (`RBS::AST::Declarations::Module`) rather than a Class. The
165
+ # `user_class_fallback_receiver` tier consults this to route
166
+ # `Nominal[M].some_kernel_method` (where M is a module mixin
167
+ # like `PP::ObjectMixin`) through the `Nominal[Object]`
168
+ # fallback, because every concrete includer of M sees Kernel
169
+ # / Object instance methods as part of its own ancestor chain.
170
+ #
171
+ # Returns false for classes, for unknown names, and when the
172
+ # RBS environment failed to build (fail-soft).
173
+ def rbs_module?(name)
174
+ return false if env.nil?
175
+
176
+ rbs_name = parse_type_name(name)
177
+ return false if rbs_name.nil?
178
+
179
+ entry = env.class_decls[rbs_name]
180
+ entry.is_a?(::RBS::Environment::ModuleEntry)
181
+ rescue ::RBS::BaseError
182
+ false
183
+ end
184
+
163
185
  # Yields every known class / module / alias name (top-level
164
186
  # prefixed) currently loaded into the environment. The cache
165
187
  # producer that materialises the known-name set uses this so
@@ -354,6 +354,19 @@ module Rigor
354
354
  @rbs_loader&.reflection
355
355
  end
356
356
 
357
+ # Returns true when the RBS environment carries the named
358
+ # declaration as a Module (not a Class). Used by the
359
+ # `user_class_fallback_receiver` tier to detect a module-mixin
360
+ # receiver (e.g. `PP::ObjectMixin`) so the dispatcher can route
361
+ # unresolved method calls through the `Nominal[Object]`
362
+ # fallback — every concrete includer of M honours Kernel /
363
+ # Object instance methods through its own ancestor chain.
364
+ def rbs_module?(name)
365
+ return false unless rbs_loader
366
+
367
+ rbs_loader.rbs_module?(name)
368
+ end
369
+
357
370
  # Compares two class/module names using analyzer-owned class data.
358
371
  # Returns `:equal`, `:subclass`, `:superclass`, `:disjoint`, or
359
372
  # `:unknown`. The static registry handles built-ins cheaply; the RBS
@@ -31,14 +31,20 @@ module Rigor
31
31
  #
32
32
  # ## Field set
33
33
  #
34
- # - `target_kind`: `:parameter` (call-site argument) or
35
- # `:self` (receiver). Future slices may extend the set
36
- # (`:local`, `:ivar`, `:result`); the merger is agnostic
37
- # to the concrete kinds and only requires equality.
34
+ # - `target_kind`: `:parameter` (call-site argument), `:self`
35
+ # (receiver), or `:local` (a named local in the surrounding
36
+ # scope). v0.1.8 Pillar 2 Slice 1 added `:local` so plugins
37
+ # recognising bespoke call shapes (`expect(x).to be_a(T)`)
38
+ # can narrow a specific scope-bound local without routing
39
+ # through the parameter-name lookup that requires an
40
+ # authoritative RBS sig on the called method. Future slices
41
+ # may extend further (`:ivar`, `:result`). The merger is
42
+ # agnostic to the concrete kinds and only requires equality.
38
43
  # - `target_name`: a `Symbol`. For `:parameter` it's the
39
44
  # declared parameter name. For `:self` it is the literal
40
45
  # `:self` symbol so the field stays non-nil and the merge
41
- # key is well-defined.
46
+ # key is well-defined. For `:local` it's the local-variable
47
+ # name (e.g. `:x` for `expect(x).to be_a(T)`).
42
48
  # - `type`: a `Rigor::Type::*` (Nominal, Refined,
43
49
  # IntegerRange, Difference, …) the fact narrows the
44
50
  # target toward (when `negative` is false) or away from
@@ -53,7 +59,7 @@ module Rigor
53
59
  # value {Element#target} keys on, so two facts that narrow
54
60
  # the same parameter from different contribution sources
55
61
  # land in the same merge bucket.
56
- FACT_VALID_TARGET_KINDS = %i[parameter self].freeze
62
+ FACT_VALID_TARGET_KINDS = %i[parameter self local].freeze
57
63
 
58
64
  class Fact < Data.define(:target_kind, :target_name, :type, :negative)
59
65
  def initialize(target_kind:, target_name:, type:, negative: false)
@@ -72,10 +78,14 @@ module Rigor
72
78
  end
73
79
 
74
80
  # Composite target identifier the merger keys on. `:self`
75
- # for self-targeted facts; otherwise `[:parameter, name]`
76
- # so two contributions that narrow the same parameter
77
- # (regardless of source family) land in the same merge
78
- # bucket.
81
+ # for self-targeted facts; otherwise `[kind, name]` so two
82
+ # contributions that narrow the same `(kind, name)` pair —
83
+ # regardless of source family land in the same merge
84
+ # bucket. `:local` and `:parameter` facts that name the
85
+ # same symbol stay in separate buckets, which is the
86
+ # correct semantics: a `:local` fact narrows the surrounding
87
+ # scope's named local, a `:parameter` fact narrows the
88
+ # call-site argument matching the parameter declaration.
79
89
  def target
80
90
  target_kind == :self ? :self : [target_kind, target_name]
81
91
  end
@@ -196,8 +196,8 @@ module Rigor
196
196
  Prism::UntilNode => :type_of_loop,
197
197
  Prism::ForNode => :type_of_dynamic_top,
198
198
  Prism::DefinedNode => :type_of_defined,
199
- Prism::NumberedReferenceReadNode => :type_of_string_or_nil,
200
- Prism::BackReferenceReadNode => :type_of_string_or_nil,
199
+ Prism::NumberedReferenceReadNode => :type_of_numbered_reference,
200
+ Prism::BackReferenceReadNode => :type_of_back_reference,
201
201
  Prism::MatchPredicateNode => :type_of_match_predicate,
202
202
  Prism::MatchRequiredNode => :type_of_match_required,
203
203
  Prism::MatchWriteNode => :type_of_dynamic_top,
@@ -347,6 +347,21 @@ module Rigor
347
347
  )
348
348
  end
349
349
 
350
+ # `$1` / `$2` / ... — numbered match-data globals. When the
351
+ # narrowing tier has bound a tighter type for this number
352
+ # (typically `String` after a `=~`-success guard like `unless
353
+ # /(\d+)/ =~ s; raise; end`), prefer the scope-bound type.
354
+ # Falls back to the default `String | nil`.
355
+ def type_of_numbered_reference(node)
356
+ scope.global(:"$#{node.number}") || type_of_string_or_nil(node)
357
+ end
358
+
359
+ # `$&` / `$'` / `$\`` / `$+` — symbolic back-references. Same
360
+ # narrowing model as numbered references.
361
+ def type_of_back_reference(node)
362
+ scope.global(node.name) || type_of_string_or_nil(node)
363
+ end
364
+
350
365
  # `expr in pattern` — pattern-match predicate. Returns `true`
351
366
  # when the pattern matches, `false` otherwise.
352
367
  def type_of_match_predicate(_node)
@@ -1010,11 +1025,15 @@ module Rigor
1010
1025
  local_def = node.receiver.nil? ? scope.top_level_def_for(node.name) : nil
1011
1026
  if local_def
1012
1027
  local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
1013
- return local_inference if local_inference
1014
-
1015
- # The local def matches by name but the
1016
- # parameter shape is too complex for the first-
1017
- # 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?`).
1018
1037
  # Returning `Dynamic[Top]` is the safest answer:
1019
1038
  # we know RBS dispatch would be wrong (the
1020
1039
  # method is user-defined and shadows whatever
@@ -1054,7 +1073,11 @@ module Rigor
1054
1073
  # the body with the call's argument types bound and
1055
1074
  # return the body's last-expression type.
1056
1075
  user_inference = try_user_method_inference(receiver, node, arg_types)
1057
- 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
1058
1081
 
1059
1082
  # Dynamic-origin propagation: when the receiver is Dynamic[T] and
1060
1083
  # no positive rule resolves the call, the result inherits the
@@ -1097,10 +1120,39 @@ module Rigor
1097
1120
  nil
1098
1121
  end
1099
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
+
1100
1152
  def try_user_method_inference(receiver, call_node, arg_types)
1101
1153
  return nil unless receiver.is_a?(Type::Nominal)
1102
1154
 
1103
- 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)
1104
1156
  return nil if def_node.nil?
1105
1157
 
1106
1158
  infer_user_method_return(def_node, receiver, arg_types)
@@ -1108,6 +1160,81 @@ module Rigor
1108
1160
  nil
1109
1161
  end
1110
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
+
1111
1238
  INFERENCE_GUARD_KEY = :__rigor_user_method_inference_stack__
1112
1239
  private_constant :INFERENCE_GUARD_KEY
1113
1240
 
@@ -1117,11 +1244,20 @@ module Rigor
1117
1244
  body_scope = build_user_method_body_scope(def_node, receiver, arg_types)
1118
1245
  return nil if body_scope.nil?
1119
1246
 
1120
- # Recursion-guard signature. Uses `describe(:short)`
1121
- # so non-Nominal receivers (e.g. the implicit
1122
- # `Object` carrier used for top-level / DSL-block
1123
- # defs in v0.0.3 A) can participate without raising.
1124
- 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]
1125
1261
  stack = (Thread.current[INFERENCE_GUARD_KEY] ||= [])
1126
1262
  return Type::Combinator.untyped if stack.include?(signature)
1127
1263
 
@@ -1159,6 +1295,8 @@ module Rigor
1159
1295
  .with_program_globals(scope.program_globals)
1160
1296
  .with_discovered_methods(scope.discovered_methods)
1161
1297
  .with_discovered_def_nodes(scope.discovered_def_nodes)
1298
+ .with_discovered_superclasses(scope.discovered_superclasses)
1299
+ .with_discovered_includes(scope.discovered_includes)
1162
1300
  .with_self_type(receiver)
1163
1301
 
1164
1302
  required.each_with_index do |param, index|
@@ -74,10 +74,28 @@ module Rigor
74
74
  # and binds the method-level type parameter that the
75
75
  # block's return type references to `block_type` (Slice 6
76
76
  # phase C sub-phase 2).
77
+ # @param self_type_override [Rigor::Type, nil] when set,
78
+ # the substitution for `Bases::Self` in the method's
79
+ # return type. Used by `MethodDispatcher#try_user_class_fallback`
80
+ # to preserve the ORIGINAL receiver as the substitute
81
+ # for `self` even though the dispatch is routed through
82
+ # `Nominal[Object]` — so that `Bundler::URI::Generic.dup`
83
+ # (which resolves through the `Object` fallback because
84
+ # `Bundler::URI::Generic` lacks RBS) returns
85
+ # `Bundler::URI::Generic` per `Kernel#dup: () -> self`
86
+ # rather than `Object`. Defaults to nil (compute self
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.
77
94
  # @return [Rigor::Type, nil] inferred return type, or `nil`
78
95
  # when no rule resolves (no class name, no method, dispatch
79
96
  # on a Top/Dynamic[Top] receiver, etc.).
80
- def try_dispatch(receiver:, method_name:, args:, environment:, block_type: nil)
97
+ def try_dispatch(receiver:, method_name:, args:, environment:, block_type: nil, self_type_override: nil,
98
+ public_only: false)
81
99
  return nil if environment.nil?
82
100
  return nil unless environment.rbs_loader
83
101
 
@@ -86,7 +104,9 @@ module Rigor
86
104
  method_name: method_name,
87
105
  args: args,
88
106
  environment: environment,
89
- block_type: block_type
107
+ block_type: block_type,
108
+ self_type_override: self_type_override,
109
+ public_only: public_only
90
110
  )
91
111
  end
92
112
 
@@ -128,32 +148,39 @@ module Rigor
128
148
  class << self
129
149
  private
130
150
 
131
- def dispatch_for(receiver:, method_name:, args:, environment:, block_type:)
151
+ def dispatch_for(receiver:, method_name:, args:, environment:, block_type:, self_type_override: nil,
152
+ public_only: false)
132
153
  args ||= []
133
154
  case receiver
134
155
  when Type::Union
135
- dispatch_union(receiver, method_name, args, environment, block_type)
156
+ dispatch_union(receiver, method_name, args, environment, block_type, self_type_override,
157
+ public_only: public_only)
136
158
  else
137
- dispatch_one(receiver, method_name, args, environment, block_type)
159
+ dispatch_one(receiver, method_name, args, environment, block_type, self_type_override,
160
+ public_only: public_only)
138
161
  end
139
162
  end
140
163
 
141
- def dispatch_union(receiver, method_name, args, environment, block_type)
164
+ def dispatch_union(receiver, method_name, args, environment, block_type, self_type_override = nil,
165
+ public_only: false)
142
166
  results = receiver.members.map do |member|
143
- dispatch_one(member, method_name, args, environment, block_type)
167
+ dispatch_one(member, method_name, args, environment, block_type, self_type_override,
168
+ public_only: public_only)
144
169
  end
145
170
  return nil if results.any?(&:nil?)
146
171
 
147
172
  Type::Combinator.union(*results)
148
173
  end
149
174
 
150
- def dispatch_one(receiver, method_name, args, environment, block_type)
175
+ def dispatch_one(receiver, method_name, args, environment, block_type, self_type_override = nil,
176
+ public_only: false)
151
177
  descriptor = receiver_descriptor(receiver)
152
178
  return nil unless descriptor
153
179
 
154
180
  class_name, kind, receiver_args = descriptor
155
181
  method_definition = lookup_method(environment, class_name, kind, method_name)
156
182
  return nil unless method_definition
183
+ return nil if public_only && method_private?(method_definition)
157
184
 
158
185
  type_vars = build_type_vars(environment, class_name, receiver_args)
159
186
  translate_return_type(
@@ -163,7 +190,8 @@ module Rigor
163
190
  args: args,
164
191
  type_vars: type_vars,
165
192
  block_type: block_type,
166
- environment: environment
193
+ environment: environment,
194
+ self_type_override: self_type_override
167
195
  )
168
196
  rescue StandardError
169
197
  # Defensive: if RBS' definition builder raises on a broken
@@ -229,6 +257,16 @@ module Rigor
229
257
  ]
230
258
  end
231
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
+
232
270
  def lookup_method(environment, class_name, kind, method_name)
233
271
  case kind
234
272
  when :instance
@@ -254,8 +292,10 @@ module Rigor
254
292
  param_names.zip(receiver_args).to_h
255
293
  end
256
294
 
295
+ # rubocop:disable Metrics/ParameterLists
257
296
  def translate_return_type(method_definition, class_name:, kind:, args:, type_vars:, block_type:,
258
- environment: nil)
297
+ environment: nil, self_type_override: nil)
298
+ # rubocop:enable Metrics/ParameterLists
259
299
  # Slice 4b-3 (ADR-7 § "Slice 4-A/4-B") — read the
260
300
  # return-type override through the merger so future
261
301
  # plugin / `:rbs_extended` bundles that also assert a
@@ -266,11 +306,17 @@ module Rigor
266
306
  return override if override
267
307
 
268
308
  instance_type = Type::Combinator.nominal_of(class_name)
269
- self_type =
309
+ resolved_self_type =
270
310
  case kind
271
311
  when :singleton then Type::Combinator.singleton_of(class_name)
272
312
  else instance_type
273
313
  end
314
+ # `self_type_override` lets the user-class fallback
315
+ # path preserve the ORIGINAL receiver as the substitute
316
+ # for `Bases::Self` — so `Kernel#dup: () -> self`
317
+ # resolved through the Object fallback returns the
318
+ # caller's type, not Object.
319
+ self_type = self_type_override || resolved_self_type
274
320
 
275
321
  method_type = OverloadSelector.select(
276
322
  method_definition,
@@ -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,27 +651,72 @@ 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)
658
658
  return nil if fallback_receiver.nil?
659
659
 
660
+ # Preserve the ORIGINAL receiver type as the `self`
661
+ # substitution so `Kernel#dup: () -> self` and other
662
+ # `self`-returning methods route through Object's RBS
663
+ # while still returning the caller's type rather than
664
+ # `Object`. Without this, `base = self.dup` inside a
665
+ # `Bundler::URI::Generic` instance method types `base`
666
+ # as `Object` because `Bundler::URI::Generic` is not in
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.
660
678
  RbsDispatch.try_dispatch(
661
679
  receiver: fallback_receiver,
662
680
  method_name: method_name,
663
681
  args: arg_types,
664
682
  environment: environment,
665
- block_type: block_type
683
+ block_type: block_type,
684
+ self_type_override: receiver_type,
685
+ public_only: explicit_non_self_receiver?(call_node)
666
686
  )
667
687
  end
668
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
+
669
706
  def user_class_fallback_receiver(receiver_type, environment)
670
707
  case receiver_type
671
708
  when Type::Nominal
672
- return nil if Rigor::Reflection.rbs_class_known?(receiver_type.class_name, environment: environment)
709
+ # Modules: even when RBS knows the module, an instance
710
+ # method on a mixin-only module (e.g. `PP::ObjectMixin`)
711
+ # observes Kernel / Object methods through every concrete
712
+ # includer's ancestor chain. Route through the
713
+ # `Nominal[Object]` fallback so `self.inspect` /
714
+ # `self.respond_to?` / `self.class` resolve cleanly when
715
+ # the module itself does not declare them.
716
+ known = Rigor::Reflection.rbs_class_known?(receiver_type.class_name, environment: environment)
717
+ return environment.nominal_for_name("Object") if !known || environment.rbs_module?(receiver_type.class_name)
673
718
 
674
- environment.nominal_for_name("Object")
719
+ nil
675
720
  when Type::Singleton
676
721
  return nil if Rigor::Reflection.rbs_class_known?(receiver_type.class_name, environment: environment)
677
722