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.
- checksums.yaml +4 -4
- data/README.md +4 -3
- data/docs/handbook/08-understanding-errors.md +3 -2
- data/docs/install.md +87 -15
- data/docs/manual/02-cli-reference.md +2 -2
- data/docs/manual/03-configuration.md +5 -0
- data/docs/manual/08-skills.md +4 -3
- data/docs/manual/12-caching.md +43 -4
- data/docs/manual/14-rails-quickstart.md +19 -11
- data/lib/rigor/analysis/check_rules/source_arity.rb +163 -59
- data/lib/rigor/analysis/check_rules.rb +118 -87
- data/lib/rigor/analysis/dependency_recorder.rb +200 -18
- data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +266 -0
- data/lib/rigor/analysis/incremental_run_slot.rb +171 -0
- data/lib/rigor/analysis/incremental_session.rb +273 -49
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
- data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
- data/lib/rigor/analysis/run_cache_key.rb +27 -8
- data/lib/rigor/analysis/run_stats.rb +2 -2
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +1 -1
- data/lib/rigor/analysis/runner/pool_coordinator.rb +2 -2
- data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
- data/lib/rigor/analysis/runner.rb +337 -46
- data/lib/rigor/analysis/shadow_harness.rb +27 -0
- data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
- data/lib/rigor/analysis/template_unit_paths.rb +6 -4
- data/lib/rigor/analysis/template_units.rb +15 -5
- data/lib/rigor/analysis/worker_session.rb +4 -3
- data/lib/rigor/cache/descriptor.rb +40 -1
- data/lib/rigor/cache/file_digest.rb +28 -0
- data/lib/rigor/cache/incremental_snapshot.rb +70 -8
- data/lib/rigor/cache/store.rb +63 -3
- data/lib/rigor/cache/value_digest.rb +325 -0
- data/lib/rigor/cli/check_command.rb +83 -13
- data/lib/rigor/cli/doctor_command.rb +1 -1
- data/lib/rigor/cli/effects_report.rb +2 -2
- data/lib/rigor/cli/unused_command.rb +3 -3
- data/lib/rigor/cli.rb +1 -1
- data/lib/rigor/config_audit.rb +3 -2
- data/lib/rigor/config_path.rb +29 -0
- data/lib/rigor/configuration/severity_profile.rb +5 -0
- data/lib/rigor/configuration.rb +6 -5
- data/lib/rigor/effects/plugin_facts.rb +6 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
- data/lib/rigor/environment/lockfile_resolver.rb +10 -1
- data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
- data/lib/rigor/environment/rbs_loader.rb +1 -1
- data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
- data/lib/rigor/environment.rb +13 -70
- data/lib/rigor/inference/block_parameter_binder.rb +29 -13
- data/lib/rigor/inference/captured_locals.rb +7 -1
- data/lib/rigor/inference/declaration_walk/context.rb +195 -0
- data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
- data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
- data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
- data/lib/rigor/inference/declaration_walk.rb +7 -0
- data/lib/rigor/inference/def_node_resolver.rb +3 -2
- data/lib/rigor/inference/definer_resolution.rb +266 -0
- data/lib/rigor/inference/expression_typer.rb +136 -45
- data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
- data/lib/rigor/inference/guard_rebinding.rb +53 -12
- data/lib/rigor/inference/macro_block_self_type.rb +14 -2
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +46 -26
- data/lib/rigor/inference/module_function_state.rb +213 -0
- data/lib/rigor/inference/multi_target_binder.rb +21 -16
- data/lib/rigor/inference/narrowing.rb +147 -17
- data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
- data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
- data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
- data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
- data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
- data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
- data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +130 -0
- data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
- data/lib/rigor/inference/scope_indexer.rb +1443 -295
- data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
- data/lib/rigor/inference/statement_evaluator.rb +80 -14
- data/lib/rigor/plugin/base.rb +28 -8
- data/lib/rigor/plugin/io_boundary.rb +82 -4
- data/lib/rigor/plugin/isolation.rb +1 -1
- data/lib/rigor/plugin/trust_policy.rb +3 -3
- data/lib/rigor/protection/discovery_seed.rb +18 -9
- data/lib/rigor/protection/mutation_cache.rb +3 -3
- data/lib/rigor/reflection/constant_ancestors.rb +96 -47
- data/lib/rigor/reflection/constant_path.rb +22 -7
- data/lib/rigor/reflection.rb +2 -3
- data/lib/rigor/scope/discovery_index.rb +218 -2
- data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
- data/lib/rigor/scope/resolution_chain.rb +884 -0
- data/lib/rigor/scope.rb +295 -322
- data/lib/rigor/sig_gen/alias_index.rb +1 -1
- data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
- data/lib/rigor/sig_gen/generator.rb +22 -16
- data/lib/rigor/type/combinator.rb +25 -1
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +10 -0
- data/sig/rigor/environment.rbs +2 -0
- data/sig/rigor/plugin/io_boundary.rbs +1 -0
- data/sig/rigor/scope.rbs +43 -1
- data/skills/rigor-project-init/SKILL.md +37 -11
- data/skills/rigor-project-init/references/02-configure.md +7 -2
- 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 =
|
|
2284
|
-
return
|
|
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
|
|
2419
|
-
#
|
|
2420
|
-
# ancestors are NOT
|
|
2421
|
-
# `try_user_method_inference` and already covers them;
|
|
2422
|
-
#
|
|
2423
|
-
#
|
|
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
|
-
{
|
|
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
|
|
2497
|
-
# whose many methods are resolved
|
|
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
|
|
2675
|
-
#
|
|
2676
|
-
#
|
|
2677
|
-
#
|
|
2678
|
-
#
|
|
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
|
-
|
|
2686
|
-
|
|
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
|
|
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 —
|
|
34
|
-
# `discovered_methods`
|
|
35
|
-
# `discovered_header_nestings
|
|
36
|
-
# that
|
|
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
|
-
|
|
71
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
248
|
-
|
|
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,
|
|
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
|