rigortype 0.4.0 → 0.4.1

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/docs/handbook/08-understanding-errors.md +3 -2
  4. data/docs/install.md +87 -15
  5. data/docs/manual/02-cli-reference.md +2 -2
  6. data/docs/manual/03-configuration.md +5 -0
  7. data/docs/manual/08-skills.md +4 -3
  8. data/docs/manual/12-caching.md +43 -4
  9. data/docs/manual/14-rails-quickstart.md +19 -11
  10. data/lib/rigor/analysis/check_rules/source_arity.rb +163 -59
  11. data/lib/rigor/analysis/check_rules.rb +118 -87
  12. data/lib/rigor/analysis/dependency_recorder.rb +200 -18
  13. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +266 -0
  14. data/lib/rigor/analysis/incremental_run_slot.rb +171 -0
  15. data/lib/rigor/analysis/incremental_session.rb +273 -49
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  17. data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
  18. data/lib/rigor/analysis/run_cache_key.rb +27 -8
  19. data/lib/rigor/analysis/run_stats.rb +2 -2
  20. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +1 -1
  21. data/lib/rigor/analysis/runner/pool_coordinator.rb +2 -2
  22. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
  23. data/lib/rigor/analysis/runner.rb +337 -46
  24. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  25. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  26. data/lib/rigor/analysis/template_unit_paths.rb +6 -4
  27. data/lib/rigor/analysis/template_units.rb +15 -5
  28. data/lib/rigor/analysis/worker_session.rb +4 -3
  29. data/lib/rigor/cache/descriptor.rb +40 -1
  30. data/lib/rigor/cache/file_digest.rb +28 -0
  31. data/lib/rigor/cache/incremental_snapshot.rb +70 -8
  32. data/lib/rigor/cache/store.rb +63 -3
  33. data/lib/rigor/cache/value_digest.rb +325 -0
  34. data/lib/rigor/cli/check_command.rb +83 -13
  35. data/lib/rigor/cli/doctor_command.rb +1 -1
  36. data/lib/rigor/cli/effects_report.rb +2 -2
  37. data/lib/rigor/cli/unused_command.rb +3 -3
  38. data/lib/rigor/cli.rb +1 -1
  39. data/lib/rigor/config_audit.rb +3 -2
  40. data/lib/rigor/config_path.rb +29 -0
  41. data/lib/rigor/configuration/severity_profile.rb +5 -0
  42. data/lib/rigor/configuration.rb +6 -5
  43. data/lib/rigor/effects/plugin_facts.rb +6 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  45. data/lib/rigor/environment/lockfile_resolver.rb +10 -1
  46. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  47. data/lib/rigor/environment/rbs_loader.rb +1 -1
  48. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  49. data/lib/rigor/environment.rb +13 -70
  50. data/lib/rigor/inference/block_parameter_binder.rb +29 -13
  51. data/lib/rigor/inference/captured_locals.rb +7 -1
  52. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  53. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  54. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  55. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  56. data/lib/rigor/inference/declaration_walk.rb +7 -0
  57. data/lib/rigor/inference/def_node_resolver.rb +3 -2
  58. data/lib/rigor/inference/definer_resolution.rb +266 -0
  59. data/lib/rigor/inference/expression_typer.rb +136 -45
  60. data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
  61. data/lib/rigor/inference/guard_rebinding.rb +53 -12
  62. data/lib/rigor/inference/macro_block_self_type.rb +14 -2
  63. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +46 -26
  64. data/lib/rigor/inference/module_function_state.rb +213 -0
  65. data/lib/rigor/inference/multi_target_binder.rb +21 -16
  66. data/lib/rigor/inference/narrowing.rb +147 -17
  67. data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
  68. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  69. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  70. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  71. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  72. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  73. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +130 -0
  74. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  75. data/lib/rigor/inference/scope_indexer.rb +1443 -295
  76. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  77. data/lib/rigor/inference/statement_evaluator.rb +80 -14
  78. data/lib/rigor/plugin/base.rb +28 -8
  79. data/lib/rigor/plugin/io_boundary.rb +82 -4
  80. data/lib/rigor/plugin/isolation.rb +1 -1
  81. data/lib/rigor/plugin/trust_policy.rb +3 -3
  82. data/lib/rigor/protection/discovery_seed.rb +18 -9
  83. data/lib/rigor/protection/mutation_cache.rb +3 -3
  84. data/lib/rigor/reflection/constant_ancestors.rb +96 -47
  85. data/lib/rigor/reflection/constant_path.rb +22 -7
  86. data/lib/rigor/reflection.rb +2 -3
  87. data/lib/rigor/scope/discovery_index.rb +218 -2
  88. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  89. data/lib/rigor/scope/resolution_chain.rb +884 -0
  90. data/lib/rigor/scope.rb +295 -322
  91. data/lib/rigor/sig_gen/alias_index.rb +1 -1
  92. data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
  93. data/lib/rigor/sig_gen/generator.rb +22 -16
  94. data/lib/rigor/type/combinator.rb +25 -1
  95. data/lib/rigor/version.rb +1 -1
  96. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +10 -0
  97. data/sig/rigor/environment.rbs +2 -0
  98. data/sig/rigor/plugin/io_boundary.rbs +1 -0
  99. data/sig/rigor/scope.rbs +43 -1
  100. data/skills/rigor-project-init/SKILL.md +37 -11
  101. data/skills/rigor-project-init/references/02-configure.md +7 -2
  102. metadata +25 -1
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Inference
5
+ # ADR-119 WD2 — the candidate-set read over {Scope::ResolutionChain}, for a reader that can decline. It is
6
+ # internal (not a `Scope` method, so the pinned public surface is unchanged) and answers one of three
7
+ # things about a method name on a class:
8
+ #
9
+ # - {Known} — every candidate gives the same answer to the question asked;
10
+ # - {UNKNOWN} — the chain does not stand for this name (a mark not discharged, a fork, a retro world that
11
+ # answers otherwise), a candidate disagrees, the walk was cut by the budget, an external ancestor ahead of
12
+ # the candidate may answer first (one RBS does not know, or knows and declares the name in; one it knows
13
+ # and whose declaration lacks the name is skipped);
14
+ # - {ABSENT} — no project entry, no external entry and not the implicit `Object` can answer.
15
+ #
16
+ # On the singleton side (ADR-119 WD3, errata 2026-10-08) the chain is `K`'s singleton, its extends' instance
17
+ # chains, then the superclass's singleton chain; an external superclass entry stands for its whole tail and is
18
+ # tested against RBS's singleton side, an extended external module against its instance side, the implicit tail
19
+ # after a project superclass against `Object`'s singleton side. A standing chain is then read past
20
+ # {SingletonHookDecline}' positional decline: a hook's singleton-side edge is recorded on no includer, so the read
21
+ # declines where one could have landed ahead of its last candidate.
22
+ #
23
+ # THE CALL SITE IS A CONTRACT. A result is consumed only by an exhaustive `case/in` that is the call's own
24
+ # direct predicate, with one arm per answer and no `else` or `in _`, and is never stored, returned or
25
+ # truth-tested: {UNKNOWN} and {ABSENT} are both truthy, so any other use folds a decline into a firing arm.
26
+ # `spec/rigor/inference/definer_resolution_case_in_spec.rb` fails on a call site of any other shape.
27
+ #
28
+ # It migrates one question at a time: `SourceArity` (C1b) and the relationship lints (C1c) read the instance
29
+ # side; no production reader reads the singleton side yet (C2-a landed it for C2's typing sites).
30
+ module DefinerResolution
31
+ Known = Data.define(:answer, :owner)
32
+ UnknownResult = Data.define
33
+ AbsentResult = Data.define
34
+ UNKNOWN = UnknownResult.new
35
+ ABSENT = AbsentResult.new
36
+
37
+ # One candidate definer as a per-chain answer function reports it: the `answer` to the question, the
38
+ # `owner` entry's name, the `index` of that entry on the chain and its `side` (`:instance` or `:singleton`).
39
+ Hit = Data.define(:answer, :owner, :index, :side)
40
+
41
+ QUESTIONS = %i[definer visibility arity override own_side].freeze
42
+ NONE = :rigor_definer_resolution_none
43
+ STRAY = :rigor_definer_resolution_stray
44
+ private_constant :NONE, :STRAY
45
+
46
+ module_function
47
+
48
+ # `answer_in` is the question's per-chain answer function: called with a chain and a position, it returns
49
+ # the {Hit} of the first project entry at or after the position that defines `method_name`, or nil. `:definer`
50
+ # and `:visibility` have a default; every other question supplies its own. It is called once per candidate
51
+ # on the chain, and again on the retro world when the chain has one fork, where `from` is mapped to the
52
+ # entry it followed.
53
+ def resolve(scope, class_name, method_name, side, question:, from: 0, &answer_in)
54
+ raise ArgumentError, "unknown question #{question.inspect}" unless QUESTIONS.include?(question)
55
+
56
+ answer_in = override_answer(scope, &answer_in) if question == :override
57
+ answer_in ||= default_answer(scope, method_name, question)
58
+ flavor = question == :arity ? :arity : :methods
59
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, side, flavor)
60
+
61
+ hits = candidates(scope, chain, method_name, from, answer_in)
62
+ return UNKNOWN if hits.nil?
63
+
64
+ verdict = chain.settle(scope, outcomes(hits), owner: hits.first&.owner, unknown_for: method_name) do |retro|
65
+ retro_from = retro_position(chain, retro, from)
66
+ outcomes(retro_from && candidates(scope, retro, method_name, retro_from, answer_in))
67
+ end
68
+ return UNKNOWN unless verdict == :chain
69
+ return UNKNOWN if side == :singleton && SingletonHookDecline.decline?(scope, chain, method_name, from, hits)
70
+
71
+ collapse(hits)
72
+ end
73
+
74
+ # The `:override` question's answer function, built from the caller's block: that block is NOT a per-chain
75
+ # function but `|owner_name| value`, called on each project entry in order, and its first non-nil `value` is
76
+ # the entry's answer, as `[owner_name, value]` (the owner is part of the answer so that two worlds naming
77
+ # different parents disagree). It is the override lints' parent read: the nearest project ancestor after the
78
+ # class whose block answers.
79
+ def override_answer(scope)
80
+ lambda do |chain, from|
81
+ first_hit(chain, scope, from) do |entry|
82
+ value = yield(entry.name)
83
+ [entry.name, value] unless value.nil?
84
+ end
85
+ end
86
+ end
87
+
88
+ # `from` is a position on `chain`; the same position on the retro world is just after the entry it followed.
89
+ # An entry the chain carries twice cannot be matched, which declines.
90
+ def retro_position(chain, retro, from)
91
+ return 0 if from.zero?
92
+
93
+ entry = chain.entries[from - 1]
94
+ return nil if entry.nil? || chain.entries.count(entry) > 1
95
+
96
+ found = retro.entries.index(entry)
97
+ found && (found + 1)
98
+ end
99
+
100
+ # The first definer and, while that definer is `possible`, the next; a nil joins the set when nothing
101
+ # follows (the absent candidate). A cut chain, and a chain on which an external ancestor ahead of the
102
+ # candidate (or, with no candidate, the implicit `Object`) may define the name, is a decline, answered by
103
+ # nil instead of a set.
104
+ def candidates(scope, chain, method_name, from, answer_in)
105
+ hits = []
106
+ position = from
107
+ loop do
108
+ hit = answer_in.call(chain, position)
109
+ hit = past_fold_copies(scope, chain, method_name, hit, answer_in) if chain.side == :singleton
110
+ return nil if hit.equal?(STRAY)
111
+
112
+ raise ArgumentError, "answer function went backwards" if hit && hit.index < position
113
+ return nil if hit.nil? && chain.truncated?
114
+
115
+ hits << hit
116
+ return decided(scope, chain, method_name, from, hits) if hit.nil? || !possible?(scope, hit, method_name)
117
+
118
+ position = hit.index + 1
119
+ end
120
+ end
121
+
122
+ # A class entry answering through a copy the extends fold made is asked past
123
+ # (`SingletonHookDecline.copy_kind`), so the module that wrote the `def` answers at its own position; a copy
124
+ # from a module the chain does not hold on that level is {STRAY}, a decline.
125
+ def past_fold_copies(scope, chain, method_name, hit, answer_in)
126
+ while hit
127
+ case SingletonHookDecline.copy_kind(scope, chain, method_name, hit)
128
+ when :copy then hit = answer_in.call(chain, hit.index + 1)
129
+ when :stray then return STRAY
130
+ else return hit
131
+ end
132
+ end
133
+ hit
134
+ end
135
+
136
+ def decided(scope, chain, method_name, from, hits)
137
+ external_may_answer?(scope, chain, method_name, from, hits.last) ? nil : hits
138
+ end
139
+
140
+ # An external entry ahead of the last candidate that RBS does not know, or knows and declares the name in,
141
+ # may be the definer Ruby calls; with no candidate, so may the implicit `Object` (`Kernel`, for the object
142
+ # that every class is). Both decline. Each tested external files the negative class edge on its spelling.
143
+ def external_may_answer?(scope, chain, method_name, from, last)
144
+ stop = last.nil? ? chain.entries.size : last.index
145
+ recording = Analysis::DependencyRecorder.active?
146
+ (from...stop).any? do |index|
147
+ entry = chain.entries[index]
148
+ next false unless entry.external?
149
+
150
+ Analysis::DependencyRecorder.read_missing(:class, entry.raw.to_s.split("::").last) if recording
151
+ !external_lacks?(scope, entry.candidates, entry.side, method_name)
152
+ end || (last.nil? && implicit_object_answers?(scope, chain, method_name))
153
+ end
154
+
155
+ # The implicit tail after a chain's last project superclass: `Object` (with `Kernel` and `BasicObject`) on the
156
+ # instance side, `#<Class:Object>` through `Class`, `Module` and `Kernel` on the singleton side.
157
+ def implicit_object_answers?(scope, chain, method_name)
158
+ !external_lacks?(scope, ["Object"], chain.side, method_name)
159
+ end
160
+
161
+ # True only for an ancestor RBS knows whose declaration lacks the name, on the side the entry stands for: an
162
+ # external superclass on a singleton chain (`:singleton`, its whole tail) is asked for a singleton method, an
163
+ # extended module (`:instance`) and every instance-side entry for an instance method.
164
+ def external_lacks?(scope, candidates, side, method_name)
165
+ return Scope::ResolutionChain::Relevance.external_lacks?(scope, candidates, method_name) if side == :instance
166
+
167
+ known = candidates.find { |candidate| Rigor::Reflection.rbs_class_known?(candidate, scope: scope) }
168
+ return false if known.nil?
169
+
170
+ Rigor::Reflection.singleton_method_definition(known, method_name, scope: scope).nil?
171
+ rescue StandardError
172
+ false
173
+ end
174
+
175
+ # What a candidate set says, for {Scope::ResolutionChain#settle} to compare across worlds.
176
+ def outcomes(hits)
177
+ return NONE if hits.nil?
178
+
179
+ hits.map { |hit| hit.nil? ? NONE : hit.answer }
180
+ end
181
+
182
+ def collapse(hits)
183
+ first = hits.first
184
+ return ABSENT if hits.all?(&:nil?)
185
+ return UNKNOWN if hits.any?(&:nil?) || hits.any? { |hit| hit.answer != first.answer }
186
+
187
+ Known.new(first.answer, first.owner)
188
+ end
189
+
190
+ # A definer that rests on a fact the walk could not prove executes: the member's `possible` sibling, or a
191
+ # contested slot of one of the single-valued members.
192
+ def possible?(scope, hit, method_name)
193
+ discovery = scope.discovery
194
+ owner = hit.owner
195
+ kind = hit.side == :singleton ? :singleton : :instance
196
+ nodes = kind == :singleton ? :discovered_singleton_def_nodes : :discovered_def_nodes
197
+ discovery.possible_method?(owner, method_name, kind) ||
198
+ discovery.contested?(nodes, [owner, method_name.to_sym]) ||
199
+ discovery.contested?(:discovered_method_visibilities, [owner, method_name.to_sym]) ||
200
+ discovery.contested?(:discovered_parameter_envelopes, [owner, [kind, method_name.to_sym]])
201
+ end
202
+
203
+ def default_answer(scope, method_name, question)
204
+ case question
205
+ when :definer then definer_answer(scope, method_name)
206
+ when :visibility then visibility_answer(scope, method_name)
207
+ else raise ArgumentError, "question #{question.inspect} needs an answer function"
208
+ end
209
+ end
210
+
211
+ # The first project entry with a `def` of the name, as `[def node, owner]`.
212
+ def definer_answer(scope, method_name)
213
+ lambda do |chain, from|
214
+ first_hit(chain, scope, from) do |entry|
215
+ node = if entry.side == :singleton
216
+ scope.singleton_def_for(entry.name,
217
+ method_name)
218
+ else
219
+ scope.user_def_for(entry.name,
220
+ method_name)
221
+ end
222
+ [node, entry.name] if node
223
+ end
224
+ end
225
+ end
226
+
227
+ # The first project entry that records the name (a discovered method, an instance `def` the def table
228
+ # holds, or a visibility change such as `private :foo` recorded for it), with its visibility (`:public` where
229
+ # none was stated).
230
+ def visibility_answer(scope, method_name)
231
+ lambda do |chain, from|
232
+ first_hit(chain, scope, from) do |entry|
233
+ kind = entry.side == :singleton ? :singleton : :instance
234
+ next unless scope.discovered_method?(entry.name, method_name, kind) ||
235
+ (kind == :instance && (scope.user_def_for(entry.name, method_name) ||
236
+ scope.discovered_method_visibility(entry.name, method_name)))
237
+
238
+ scope.discovered_method_visibility(entry.name, method_name) || :public
239
+ end
240
+ end
241
+ end
242
+
243
+ def first_hit(chain, scope, from)
244
+ index = from - 1
245
+ found = nil
246
+ chain.search(scope, from) do |entry|
247
+ index += 1
248
+ next if entry.external?
249
+
250
+ answer = yield(entry)
251
+ next if answer.nil?
252
+
253
+ found = Hit.new(answer, entry.name, index, entry.side)
254
+ end
255
+ found
256
+ end
257
+
258
+ private_class_method :override_answer, :retro_position, :candidates, :past_fold_copies, :decided,
259
+ :external_may_answer?, :implicit_object_answers?, :external_lacks?,
260
+ :outcomes, :collapse, :possible?,
261
+ :default_answer, :definer_answer, :visibility_answer, :first_hit
262
+ end
263
+ end
264
+ end
265
+
266
+ require_relative "singleton_hook_decline"
@@ -2280,8 +2280,8 @@ module Rigor
2280
2280
  def try_user_method_inference(receiver, call_node, arg_types, method_name: call_node.name, block_type: nil)
2281
2281
  return nil unless user_inference_receiver?(receiver)
2282
2282
 
2283
- def_node, owner = resolve_user_def_with_owner(receiver.class_name, method_name)
2284
- return nil if def_node.nil?
2283
+ def_node, owner, raising = resolve_typing_def_with_owner(receiver.class_name, method_name)
2284
+ return every_definer_raises_type(raising, receiver, call_node, arg_types, block_type) if def_node.nil?
2285
2285
 
2286
2286
  result = infer_user_method_return(def_node, receiver, arg_types,
2287
2287
  self_fold_safe: fold_safe_call_receiver?(call_node, receiver),
@@ -2383,8 +2383,7 @@ module Rigor
2383
2383
  key = method_name.to_sym
2384
2384
  return table[key] if table.key?(key)
2385
2385
 
2386
- table[key] = scope.singleton_def_through_ancestors(class_name, method_name,
2387
- name_memo: class_graph_buckets[:name])
2386
+ table[key] = scope.singleton_def_through_ancestors(class_name, method_name)
2388
2387
  end
2389
2388
 
2390
2389
  # The project-side singleton-method band: a `Foo.bar` call resolved against a `class << …` / `def self.…`
@@ -2415,13 +2414,12 @@ module Rigor
2415
2414
  nil
2416
2415
  end
2417
2416
 
2418
- # ADR-24 slice 2 — resolves `method_name` against `class_name`'s own `def`s, then walks the user-class
2419
- # ancestor chain: included / prepended modules (transitive) and the superclass chain. RBS-known
2420
- # ancestors are NOT walked here — the `MethodDispatcher` RBS tier runs before
2421
- # `try_user_method_inference` and already covers them; an ancestor name that resolves to no
2422
- # project-discovered class/module ends that branch. Cross-file: the chain is followed through
2423
- # `Scope#discovered_superclasses` / `#discovered_includes` / `#discovered_def_nodes`, which the runner
2424
- # seeds from the project-wide pre-pass. The walk is breadth-first, cycle-guarded, and node-count-capped.
2417
+ # ADR-24 slice 2 — resolves `method_name` against the project definers on `class_name`'s
2418
+ # {Scope::ResolutionChain}, in Ruby's order: prepended modules, the class, its included modules, then the
2419
+ # superclass's chain likewise. RBS-known ancestors are NOT answered here — the `MethodDispatcher` RBS
2420
+ # tier runs before `try_user_method_inference` and already covers them; they sit on the chain as
2421
+ # external entries this lookup passes over. Cross-file: the chain is built from the tables the runner
2422
+ # seeds from the project-wide pre-pass, cycle-guarded and capped at `Scope::ANCESTOR_WALK_LIMIT`.
2425
2423
 
2426
2424
  CLASS_GRAPH_CACHE_KEY = :__rigor_class_graph_cache__
2427
2425
  private_constant :CLASS_GRAPH_CACHE_KEY
@@ -2464,13 +2462,114 @@ module Rigor
2464
2462
  # is a pure function of the same frozen index — the sibling resolver it walks reads nothing else.
2465
2463
  # `singleton_def` is added lazily by {#singleton_def_through_ancestors}'s caller.
2466
2464
  slot = [discovery,
2467
- { name: {}, user_def: {}, self_pure: {}.compare_by_identity,
2468
- yields: {}.compare_by_identity }]
2465
+ { user_def: {}, typing_def: {}, self_pure: {}.compare_by_identity, yields: {}.compare_by_identity }]
2469
2466
  Thread.current[CLASS_GRAPH_CACHE_KEY] = slot
2470
2467
  end
2471
2468
  slot[1]
2472
2469
  end
2473
2470
 
2471
+ # ADR-119 C2-b1 — the TYPING read of {#resolve_user_def_with_owner}: the same `[def_node, owner]` pair, but
2472
+ # answered through {DefinerResolution} so a chain that does not stand for the name (a conditional definer,
2473
+ # a conditional include, a fork, an external ancestor that may answer first) is `[nil, nil]` and the call
2474
+ # types `Dynamic` instead of from a definer Ruby may not reach. ABSENT is the same `[nil, nil]`: at a typing
2475
+ # site "no definer" and "cannot say" both fall through to `dispatch_miss_result`. Existence reads
2476
+ # ({#resolve_user_def_through_ancestors}, the self-purity scan) stay on the union memo above: an existence
2477
+ # read turning false there could ADD a `call.undefined-method` firing.
2478
+ #
2479
+ # UNKNOWN carries a third element, the `bot` exception's definers ({#every_project_definer}): the def nodes
2480
+ # of every project entry on the chain that defines the name, or nil where the exception cannot apply.
2481
+ def resolve_typing_def_with_owner(class_name, method_name)
2482
+ cache = class_graph_buckets[:typing_def]
2483
+ table = (cache[class_name.to_s] ||= {})
2484
+ key = method_name.to_sym
2485
+ return table[key] if table.key?(key)
2486
+
2487
+ table[key] =
2488
+ case DefinerResolution.resolve(scope, class_name, method_name, :instance, question: :definer)
2489
+ in DefinerResolution::Known(answer: [node, owner])
2490
+ [node, owner]
2491
+ in DefinerResolution::UNKNOWN
2492
+ [nil, nil, every_project_definer(class_name, method_name)]
2493
+ in DefinerResolution::ABSENT
2494
+ [nil, nil]
2495
+ end
2496
+ end
2497
+
2498
+ # ADR-119 C2-b1 errata (the `bot` exception) — an UNKNOWN chain does not say WHICH definer Ruby calls, but
2499
+ # when every project definer of the name on the chain types `bot` (each raises or exits), the call cannot
2500
+ # return whichever of them runs, so it types `bot` and a guard such as `fail_with 'no' unless count` still
2501
+ # narrows. `defs` is {#every_project_definer}'s list; each is inferred with this call's receiver and
2502
+ # arguments, stopping at the first that returns (the answer is then `Dynamic` whatever the rest say).
2503
+ # Accepted cost: an RBS-unknown external ahead of them that really defines the name and returns is a
2504
+ # narrowing false negative; `bot` reports nothing about a value, and that exposure is master's.
2505
+ def every_definer_raises_type(defs, receiver, call_node, arg_types, block_type)
2506
+ return nil if defs.nil?
2507
+
2508
+ self_fold_safe = fold_safe_call_receiver?(call_node, receiver)
2509
+ defs.each do |def_node|
2510
+ result = infer_user_method_return(def_node, receiver, arg_types,
2511
+ self_fold_safe: self_fold_safe, yield_type: block_type)
2512
+ return nil unless result.is_a?(Type::Bot)
2513
+ end
2514
+ Type::Combinator.bot
2515
+ end
2516
+
2517
+ # The def node of every project entry on `class_name`'s instance chain that defines `method_name`, in any
2518
+ # position (the order is what UNKNOWN does not know), or nil where the `bot` exception cannot apply: a cut
2519
+ # chain, no project definer, an entry whose only record of the name has no body (an `attr_*` or a
2520
+ # `define_method` with no `def`), a definer resting on a `possible` or contested fact (another body may be the
2521
+ # one Ruby holds), or an external RBS knows that declares the name. An external RBS does not know is passed
2522
+ # over. Only what the tables hold is read: a `define_method` beside an entry's own `def` of the name, and a
2523
+ # receiver-form `X.include(M)` outside `X`'s body, are not seen (as on the Known path).
2524
+ #
2525
+ # ADR-46: the answer depends on every entry, so each files its class edge and its negative class edge (a new
2526
+ # file reopening it may add a definer or a mixin), and each project entry's {Scope#user_def_for} files the
2527
+ # method edge, positive for a definer and `read_missing(:method, "Owner#name")` for one that lacks the
2528
+ # `def`. `DefinerResolution`'s decline filed none of this past the external that stopped it.
2529
+ def every_project_definer(class_name, method_name)
2530
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, :instance, :methods)
2531
+ chain.record(scope)
2532
+ return nil if chain.truncated?
2533
+
2534
+ defs = []
2535
+ declined = false
2536
+ chain.entries.uniq.each do |entry|
2537
+ if entry.external?
2538
+ note_missing_class(entry.raw.to_s.split("::").last)
2539
+ declined = true if external_declares?(entry.candidates, method_name)
2540
+ next
2541
+ end
2542
+ note_missing_class(entry.last_segment)
2543
+ node = scope.user_def_for(entry.name, method_name)
2544
+ defs << node if node
2545
+ declined = true if definer_without_single_body?(entry.name, method_name, node)
2546
+ end
2547
+ declined || defs.empty? ? nil : defs.freeze
2548
+ end
2549
+
2550
+ def note_missing_class(segment)
2551
+ Analysis::DependencyRecorder.read_missing(:class, segment) if Analysis::DependencyRecorder.active?
2552
+ end
2553
+
2554
+ def definer_without_single_body?(owner, method_name, node)
2555
+ discovery = scope.discovery
2556
+ key = [owner, method_name.to_sym]
2557
+ (node.nil? && scope.discovered_method?(owner, method_name, :instance)) ||
2558
+ discovery.possible_method?(owner, method_name, :instance) ||
2559
+ discovery.contested?(:discovered_def_nodes, key)
2560
+ end
2561
+
2562
+ # True where RBS knows one of the names the external's spelling can denote and declares the instance method
2563
+ # in it; a lookup that fails counts as declaring (the exception then does not apply).
2564
+ def external_declares?(candidates, method_name)
2565
+ known = candidates.find { |candidate| Rigor::Reflection.rbs_class_known?(candidate, scope: scope) }
2566
+ return false if known.nil?
2567
+
2568
+ !Rigor::Reflection.instance_method_definition(known, method_name, scope: scope).nil?
2569
+ rescue StandardError
2570
+ true
2571
+ end
2572
+
2474
2573
  def resolve_user_def_through_ancestors(class_name, method_name)
2475
2574
  resolve_user_def_with_owner(class_name, method_name).first
2476
2575
  end
@@ -2493,11 +2592,10 @@ module Rigor
2493
2592
  # nothing but the frozen discovery index, and the `.with` guard in
2494
2593
  # {MethodDispatcher::StructMaterialization} needs the SAME answer without threading dispatcher state
2495
2594
  # through the dispatcher (#598 review). What stays here is the caching: the run-scoped
2496
- # `(class_name, method_name)` memo above, plus the per-edge name bucket handed to the walk so a class
2497
- # whose many methods are resolved pays each ancestor edge once.
2595
+ # `(class_name, method_name)` memo above. The chain itself is memoised per class by
2596
+ # {Scope::ResolutionChain}, so a class whose many methods are resolved builds it once.
2498
2597
  def compute_user_def_with_owner(class_name, method_name)
2499
- scope.user_def_through_ancestors(class_name, method_name,
2500
- name_memo: class_graph_buckets[:name])
2598
+ scope.user_def_through_ancestors(class_name, method_name)
2501
2599
  end
2502
2600
 
2503
2601
  # ADR-57 N5 — overridable-method adoption gate. A self-call resolved to a project `def` whose owner has
@@ -2671,34 +2769,14 @@ module Rigor
2671
2769
  index
2672
2770
  end
2673
2771
 
2674
- # True when `candidate`'s transitive ancestor chain (superclasses + included/prepended modules) reaches
2675
- # `owner` — i.e. `candidate` is a subclass of an owner class or an includer of an owner module. Reuses
2676
- # the same BFS resolver the method-resolution ancestor walk uses, so name resolution (lexical nesting,
2677
- # RBS-known-ancestor pruning) is identical.
2678
- # Delegates to the shared walk's edge step so this BFS and method resolution resolve ancestor names
2679
- # identically, sharing the per-edge memo bucket.
2680
- def enqueue_ancestors(current, queue)
2681
- scope.enqueue_ancestors(current, queue, class_graph_buckets[:name])
2682
- end
2683
-
2772
+ # True when `candidate`'s ancestor chain (superclasses + included/prepended modules) reaches `owner` —
2773
+ # i.e. `candidate` is a subclass of an owner class or an includer of an owner module. Asked of the same
2774
+ # {Scope::ResolutionChain} method resolution reads, so name resolution (lexical nesting, RBS-known-ancestor
2775
+ # pruning) is identical; a chain cut at the budget that does not reach `owner` answers false, as the walk
2776
+ # it replaced did. Reachability is the same in both of the chain's worlds.
2684
2777
  def related_to_owner?(candidate, owner)
2685
- queue = []
2686
- enqueue_ancestors(candidate, queue)
2687
- seen = {}
2688
- visited = 0
2689
- until queue.empty?
2690
- current = queue.shift
2691
- next if current.nil? || seen[current]
2692
-
2693
- return true if current == owner
2694
-
2695
- seen[current] = true
2696
- visited += 1
2697
- return false if visited > Scope::ANCESTOR_WALK_LIMIT
2698
-
2699
- enqueue_ancestors(current, queue)
2700
- end
2701
- false
2778
+ chain = Scope::ResolutionChain.for(scope, candidate.to_s, :instance, :methods)
2779
+ !chain.search(scope) { |entry| entry.name == owner }.nil?
2702
2780
  end
2703
2781
 
2704
2782
  INFERENCE_GUARD_KEY = :__rigor_user_method_inference_stack__
@@ -3860,15 +3938,28 @@ module Rigor
3860
3938
  # Both are precision-only. A `...` outside a forwarding frame (unreachable in valid Ruby) and a double
3861
3939
  # splat of anything but a Symbol-keyed closed `HashShape` — a shapeless `Hash[Symbol, V]`, an opaque
3862
3940
  # value, a mixed hash — fall back to exactly the pre-#1125 answer.
3941
+ #
3942
+ # Only a list holding a `...` takes the flat_map. Every call is typed through here, and the flat_map's
3943
+ # one-element Array per argument cost `rigor check` about 315K allocations over the v0.3.9 `lib`
3944
+ # ([#1503](https://github.com/rigortype/rigor/issues/1503)).
3863
3945
  def call_arg_types(node)
3864
3946
  arguments_node = node.arguments
3865
3947
  return [] if arguments_node.nil?
3866
3948
 
3867
- arguments_node.arguments.flat_map do |argument|
3949
+ arguments = arguments_node.arguments
3950
+ return arguments.map { |argument| call_arg_type(argument) } unless forwards_arguments?(arguments)
3951
+
3952
+ arguments.flat_map do |argument|
3868
3953
  forwarded_argument_types(argument) || [call_arg_type(argument)]
3869
3954
  end
3870
3955
  end
3871
3956
 
3957
+ # Whether a call's argument list holds `...`. Ruby accepts it only last; the scan checks every argument
3958
+ # rather than rely on that.
3959
+ def forwards_arguments?(arguments)
3960
+ arguments.any?(Prism::ForwardingArgumentsNode)
3961
+ end
3962
+
3872
3963
  # The frame's argument list when `argument` is `...`, else nil so the caller types it normally.
3873
3964
  def forwarded_argument_types(argument)
3874
3965
  return nil unless argument.is_a?(Prism::ForwardingArgumentsNode)
@@ -30,10 +30,11 @@ module Rigor
30
30
  #
31
31
  # There is deliberately NO memo here. The dispatch consumer this module is being extracted FOR does
32
32
  # not exist yet, and a memo keyed on the tables this slice happens to name would be keyed on less
33
- # than the walk reads — `Scope#resolve_ancestor_class_name` consults `discovered_def_nodes` and
34
- # `discovered_methods` through `known_user_class?`, and `ancestor_name_candidates` reads
35
- # `discovered_header_nestings`. The slice that brings the hot path brings the memo, keyed on what
36
- # that consumer actually reads.
33
+ # than the walk reads — the ancestor chain resolves names through `known_user_class?`
34
+ # (`discovered_def_nodes`, `discovered_methods`) and `ancestor_name_candidates`
35
+ # (`discovered_header_nestings`). The chain itself is memoised per discovery index
36
+ # ({Scope::ResolutionChain}); the slice that brings the hot path brings this module's memo, keyed on
37
+ # what that consumer actually reads.
37
38
  module ExternalAncestorResolution
38
39
  # The owner a name must PRECEDE for its declaration to win an MRO. See {declared_before_object?}.
39
40
  OBJECT_OWNER = "Object"
@@ -67,13 +68,12 @@ module Rigor
67
68
  # `include Enumerable` so that slice 2's measurement stays its own, and the narrowing belongs to
68
69
  # the CALLER rather than to this module: the implicit-self veto must keep walking both edges,
69
70
  # because Ruby reaches an included module's methods too.
70
- # rubocop:disable-next Metrics/ParameterLists
71
- def resolve(class_name, method_name, kind = :instance, scope:, environment: nil, name_memo: nil,
72
- record_dependencies: true, mixins: true)
71
+ def resolve(class_name, method_name, kind = :instance, scope:, environment: nil, record_dependencies: true,
72
+ mixins: true)
73
73
  return nil if class_name.nil? || scope.nil?
74
74
  return nil unless kind == :instance
75
75
 
76
- compute(class_name, method_name, scope, environment, name_memo, record_dependencies, mixins)
76
+ compute(class_name, method_name, scope, environment, record_dependencies, mixins)
77
77
  end
78
78
 
79
79
  # The RBS method definition for `class_name`, or nil for a class the environment does not know, a
@@ -133,7 +133,7 @@ module Rigor
133
133
  []
134
134
  end
135
135
 
136
- def compute(class_name, method_name, scope, environment, name_memo, record_dependencies, mixins)
136
+ def compute(class_name, method_name, scope, environment, record_dependencies, mixins)
137
137
  kind = :instance
138
138
  own = method_definition(class_name, method_name, kind, scope: scope, environment: environment)
139
139
  if own
@@ -144,7 +144,7 @@ module Rigor
144
144
  end
145
145
  end
146
146
 
147
- groups = ancestor_candidate_groups(scope, class_name, name_memo, record_dependencies, mixins)
147
+ groups = ancestor_candidate_groups(scope, class_name, record_dependencies, mixins)
148
148
  farther_ancestors = {}
149
149
  groups.each_with_index do |candidates, index|
150
150
  answer = first_known_candidate_answer(candidates, method_name, kind, scope, environment)
@@ -244,14 +244,11 @@ module Rigor
244
244
  # The walk, with its ADR-46 reads attached or detached. `withhold` returns `[result, read_set]`
245
245
  # and the read set is dropped: a caller that suppresses is saying these reads are not a dependency
246
246
  # of its answer, not that they should be replayed somewhere else.
247
- def ancestor_candidate_groups(scope, class_name, name_memo, record_dependencies, mixins)
248
- memo = name_memo || {}
249
- if record_dependencies
250
- return scope.external_ancestor_name_candidates(class_name, name_memo: memo, mixins: mixins)
251
- end
247
+ def ancestor_candidate_groups(scope, class_name, record_dependencies, mixins)
248
+ return scope.external_ancestor_name_candidates(class_name, mixins: mixins) if record_dependencies
252
249
 
253
250
  Analysis::DependencyRecorder.withhold do
254
- scope.external_ancestor_name_candidates(class_name, name_memo: memo, mixins: mixins)
251
+ scope.external_ancestor_name_candidates(class_name, mixins: mixins)
255
252
  end.first
256
253
  end
257
254
  private_class_method :ancestor_candidate_groups