rigortype 0.4.5 → 0.4.6
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 +1 -1
- data/docs/handbook/11-sig-gen.md +11 -2
- data/docs/manual/02-cli-reference.md +46 -7
- data/docs/manual/03-configuration.md +2 -1
- data/docs/manual/04-diagnostics.md +1 -1
- data/docs/manual/10-mcp-server.md +9 -1
- data/docs/manual/12-caching.md +8 -1
- data/docs/manual/plugins/rigor-actionmailer.md +30 -0
- data/docs/manual/plugins/rigor-actionpack.md +30 -0
- data/docs/manual/plugins/rigor-devise.md +15 -0
- data/docs/manual/plugins/rigor-rspec.md +2 -1
- data/lib/rigor/analysis/buffer_binding.rb +14 -0
- data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -0
- data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
- data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
- data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
- data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +51 -0
- data/lib/rigor/analysis/check_rules.rb +93 -8
- data/lib/rigor/analysis/dependency_recorder.rb +11 -0
- data/lib/rigor/analysis/edit_replay.rb +67 -0
- data/lib/rigor/analysis/incremental.rb +37 -0
- data/lib/rigor/analysis/incremental_session.rb +175 -27
- data/lib/rigor/analysis/path_expansion.rb +41 -0
- data/lib/rigor/analysis/require_relative_closure.rb +100 -0
- data/lib/rigor/analysis/rule_catalog.rb +5 -1
- data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
- data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
- data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
- data/lib/rigor/analysis/runner/pool_coordinator.rb +17 -4
- data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
- data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
- data/lib/rigor/analysis/runner.rb +156 -49
- data/lib/rigor/analysis/worker_session.rb +14 -2
- data/lib/rigor/cache/incremental_snapshot.rb +30 -7
- data/lib/rigor/cli/check_command.rb +93 -4
- data/lib/rigor/cli/diff_command.rb +2 -1
- data/lib/rigor/cli/lens_command.rb +5 -5
- data/lib/rigor/cli/mcp_command.rb +2 -1
- data/lib/rigor/cli/options.rb +20 -3
- data/lib/rigor/cli/plugins_command.rb +5 -0
- data/lib/rigor/cli/plugins_renderer.rb +24 -2
- data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
- data/lib/rigor/cli/sig_gen_command.rb +46 -7
- data/lib/rigor/cli/type_of_command.rb +2 -1
- data/lib/rigor/cli/type_of_project_scope.rb +27 -0
- data/lib/rigor/config_audit.rb +16 -5
- data/lib/rigor/configuration.rb +14 -4
- data/lib/rigor/environment/rbs_loader.rb +115 -6
- data/lib/rigor/environment/required_features.rb +136 -29
- data/lib/rigor/environment/ruby_library_roots.rb +122 -0
- data/lib/rigor/environment.rb +4 -2
- data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
- data/lib/rigor/inference/deep_erasure.rb +138 -0
- data/lib/rigor/inference/def_return_typer.rb +159 -9
- data/lib/rigor/inference/definer_resolution.rb +17 -3
- data/lib/rigor/inference/error_info.rb +17 -3
- data/lib/rigor/inference/expression_typer.rb +62 -13
- data/lib/rigor/inference/global_write_census.rb +36 -1
- data/lib/rigor/inference/ivar_writer_census.rb +195 -0
- data/lib/rigor/inference/loop_value_record.rb +53 -0
- data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +110 -9
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
- data/lib/rigor/inference/method_dispatcher.rb +46 -16
- data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
- data/lib/rigor/inference/module_function_state.rb +12 -0
- data/lib/rigor/inference/mutation_widening.rb +5 -2
- data/lib/rigor/inference/narrowing.rb +66 -5
- data/lib/rigor/inference/object_mixins.rb +13 -1
- data/lib/rigor/inference/project_seed_tables.rb +2 -1
- data/lib/rigor/inference/project_yielder.rb +128 -0
- data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
- data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
- data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
- data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
- data/lib/rigor/inference/scope_indexer.rb +480 -103
- data/lib/rigor/inference/self_call_ivar_writes.rb +18 -6
- data/lib/rigor/inference/statement_evaluator.rb +658 -90
- data/lib/rigor/inference/synthetic_method_index.rb +23 -0
- data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
- data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
- data/lib/rigor/language_server/buffer_table.rb +6 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
- data/lib/rigor/language_server/project_context.rb +59 -3
- data/lib/rigor/language_server/server.rb +5 -3
- data/lib/rigor/mcp/server.rb +9 -1
- data/lib/rigor/plugin/base.rb +26 -11
- data/lib/rigor/plugin/manifest.rb +29 -3
- data/lib/rigor/plugin/registry.rb +38 -0
- data/lib/rigor/protection/discovery_seed.rb +57 -2
- data/lib/rigor/rooted_glob.rb +10 -1
- data/lib/rigor/scope.rb +14 -5
- data/lib/rigor/sig_gen/generator.rb +308 -37
- data/lib/rigor/sig_gen/observation_collector.rb +4 -1
- data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
- data/lib/rigor/sig_gen/renderer.rb +6 -3
- data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
- data/lib/rigor/type/combinator.rb +4 -3
- data/lib/rigor/type/constant.rb +45 -3
- data/lib/rigor/type/hash_shape.rb +14 -9
- data/lib/rigor/type/struct_instance.rb +3 -2
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +60 -43
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +6 -5
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
- data/sig/rigor/analysis/baseline.rbs +3 -2
- data/sig/rigor/effects/file_collection.rbs +5 -6
- data/sig/rigor/effects/registry.rbs +8 -6
- data/sig/rigor/effects/summary.rbs +6 -5
- data/sig/rigor/environment.rbs +5 -3
- data/sig/rigor/inference/optimistic_origin.rbs +11 -0
- data/sig/rigor/inference.rbs +6 -0
- data/sig/rigor/plugin/additional_initializer.rbs +5 -5
- data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
- data/sig/rigor/plugin/effect_attribution.rbs +25 -10
- data/sig/rigor/plugin/effect_edge.rbs +12 -5
- data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
- data/sig/rigor/plugin/io_boundary.rbs +4 -4
- data/sig/rigor/plugin/manifest.rbs +16 -18
- data/sig/rigor/plugin/protocol_contract.rbs +10 -9
- data/sig/rigor/plugin/registry.rbs +1 -1
- data/sig/rigor/scope.rbs +31 -50
- data/sig/rigor/type.rbs +0 -6
- metadata +20 -1
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../dependency_recorder"
|
|
6
|
+
require_relative "../../inference/scope_indexer"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Analysis
|
|
10
|
+
module CheckRules
|
|
11
|
+
# Issue #1817 — `call.unresolved-toplevel` declines inside a block handed to a project method that may run it
|
|
12
|
+
# under another `self`. `def self.define(&blk) = module_eval(&blk)` makes `Ext.define { refine(String) { … } }`
|
|
13
|
+
# call `refine` on `Ext`, not on `main`, so the call is not a top-level one even though the block sits at the
|
|
14
|
+
# top of the file.
|
|
15
|
+
#
|
|
16
|
+
# The reading is by method NAME across every project definition, never by the receiver's type: any project
|
|
17
|
+
# `def` of the block's callee name that may hand the block to something else declines the rule. That
|
|
18
|
+
# over-reads (an unrelated class's same-named method declines too), which only loses a report; resolving the
|
|
19
|
+
# receiver would read one class and could miss the definition Ruby actually dispatches to, which reports on
|
|
20
|
+
# correct code. A definition "may hand the block on" unless every use of it is provably a plain call:
|
|
21
|
+
#
|
|
22
|
+
# - its body only `yield`s, or reads the block parameter only as the receiver of `call` / `()` / `[]` /
|
|
23
|
+
# `yield` / `===` / `arity` / `lambda?` / `parameters` / `nil?` / `!`, or as an `if` / `unless` / `while` /
|
|
24
|
+
# `until` predicate or the left side of `&&` — each of which runs the block with its own `self` or not at
|
|
25
|
+
# all;
|
|
26
|
+
# - any other read (`module_eval(&blk)`, `other(&blk)`, `@blk = blk`, `return blk`), an anonymous `&` or a
|
|
27
|
+
# `...` forwarded anywhere, a `super` that passes the block on implicitly, or a `binding` / `eval` that
|
|
28
|
+
# could reach it, may rebind `self`. Where in the body it sits does not matter: an eval under an `if` may
|
|
29
|
+
# run.
|
|
30
|
+
#
|
|
31
|
+
# A callee name the project gives a method without a readable `def` (`attr_*`, `alias_method`,
|
|
32
|
+
# `define_method`, `define_singleton_method` on any receiver, a `def` written inside a block), or patches
|
|
33
|
+
# through `pre_eval:`, cannot be read and declines too. So does a `send` / `__send__` / `public_send` whose
|
|
34
|
+
# method name is not a literal. Issue #2184 — a project `method_missing` that may hand its block on answers
|
|
35
|
+
# for every name, since Ruby reaches it for any name the receiver lacks; and the block of a `define_method` /
|
|
36
|
+
# `define_singleton_method` call is a method body, run under whatever receives the method. A callee no project
|
|
37
|
+
# file defines keeps the rule's old answer otherwise: Rigor reads only the project's own bodies here.
|
|
38
|
+
module RebindingBlockCallees
|
|
39
|
+
SEND_CALLS = %i[send __send__ public_send].freeze
|
|
40
|
+
# Reading the block through these runs it with its own `self`, or does not run it.
|
|
41
|
+
PLAIN_PROC_CALLS = %i[call yield [] === ! arity lambda? parameters nil?].freeze
|
|
42
|
+
# Calls, on any receiver, that can reach a local by name: a `Binding`, a string `eval`, and the string forms of
|
|
43
|
+
# the `*_eval` family (with an argument), whose code runs in the caller's scope.
|
|
44
|
+
BINDING_CALLS = %i[binding eval local_variable_get].freeze
|
|
45
|
+
STRING_EVAL_CALLS = %i[instance_eval class_eval module_eval].freeze
|
|
46
|
+
private_constant :STRING_EVAL_CALLS
|
|
47
|
+
PREDICATE_PARENTS = [Prism::IfNode, Prism::UnlessNode, Prism::WhileNode, Prism::UntilNode].freeze
|
|
48
|
+
# `proc { … }`, `lambda { … }` and `Proc.new { … }` passed with `&` are the block of the call they go to.
|
|
49
|
+
PROC_LITERAL_CALLS = %i[proc lambda].freeze
|
|
50
|
+
# Core methods that run their block under another `self`: as a method body, or under the receiver (#2186:
|
|
51
|
+
# also when the block is a proc passed through a local, `Ext.instance_exec(&blk)`).
|
|
52
|
+
REBINDING_CORE_CALLS = %i[
|
|
53
|
+
define_method define_singleton_method
|
|
54
|
+
instance_eval instance_exec class_eval class_exec module_eval module_exec
|
|
55
|
+
].freeze
|
|
56
|
+
private_constant :REBINDING_CORE_CALLS
|
|
57
|
+
private_constant :SEND_CALLS, :PLAIN_PROC_CALLS, :BINDING_CALLS, :PREDICATE_PARENTS, :PROC_LITERAL_CALLS
|
|
58
|
+
|
|
59
|
+
# The calls in one file that pass a literal block, each with the offsets that block's body spans, plus the
|
|
60
|
+
# file's memo of callee answers. Built on the first ask, so a file whose would-fire calls all decline earlier
|
|
61
|
+
# never walks. One file is one ADR-46 consumer, so an answer's dependency edges, recorded on its first ask,
|
|
62
|
+
# hold for every later call in the file that reuses it.
|
|
63
|
+
#
|
|
64
|
+
# Issue #2186 — a proc literal that is a value rather than a call's block (`blk = proc { … }`) adds a row for
|
|
65
|
+
# each call that receives it as `&blk`, when every other read of that local only calls or tests it. A literal
|
|
66
|
+
# called in place (`proc { … }.call`) runs with its own `self` and adds none. Any other proc value (stored,
|
|
67
|
+
# passed positionally, read another way, or in a method (or the code around it) that may read locals by name
|
|
68
|
+
# through `binding` / `eval` / `local_variable_get` or a computed `send`) cannot be followed to the call that
|
|
69
|
+
# runs it, so its row is {UNKNOWN}, which declines. A heredoc that opens inside a block adds a row over its
|
|
70
|
+
# body: Prism puts the body after the block's closing brace, but Ruby runs its interpolation inside the block.
|
|
71
|
+
class Sites
|
|
72
|
+
# The target of a row whose block Rigor cannot follow to the call that runs it.
|
|
73
|
+
UNKNOWN = :unknown
|
|
74
|
+
|
|
75
|
+
attr_reader :answers
|
|
76
|
+
|
|
77
|
+
def initialize(root)
|
|
78
|
+
@root = root
|
|
79
|
+
@rows = nil
|
|
80
|
+
@answers = {}
|
|
81
|
+
@def_rows = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The block-passing calls (or {UNKNOWN}) whose block contains `node`. Rows are sorted by start, so the scan
|
|
85
|
+
# stops at the first block that starts after `node`.
|
|
86
|
+
def enclosing(node)
|
|
87
|
+
build
|
|
88
|
+
start = node.location.start_offset
|
|
89
|
+
stop = node.location.end_offset
|
|
90
|
+
found = []
|
|
91
|
+
@rows.each do |from, to, call|
|
|
92
|
+
break if from > start
|
|
93
|
+
|
|
94
|
+
found << call if stop <= to
|
|
95
|
+
end
|
|
96
|
+
found
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# `{name => {[owner, side] => rows}}` over the project's named `def` rows, built once per file.
|
|
100
|
+
def def_rows(scope)
|
|
101
|
+
@def_rows ||= RebindingBlockCallees.def_row_counts(scope)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def build
|
|
107
|
+
return if @rows
|
|
108
|
+
|
|
109
|
+
@rows = []
|
|
110
|
+
@bodies = []
|
|
111
|
+
@heredocs = []
|
|
112
|
+
@proc_values = []
|
|
113
|
+
@local_reads = Hash.new { |hash, name| hash[name] = [] }
|
|
114
|
+
@block_passes = Hash.new { |hash, name| hash[name] = [] }
|
|
115
|
+
@scopes_reading_locals = Set.new.compare_by_identity
|
|
116
|
+
walk unless @root.nil?
|
|
117
|
+
# A block call chained on another (`m { }.then { }`) is met after the call whose block starts later.
|
|
118
|
+
@rows.sort_by!(&:first)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def walk
|
|
122
|
+
collect(@root, nil, @root)
|
|
123
|
+
@proc_values.each do |body, parent, local_scope|
|
|
124
|
+
value_targets(parent, local_scope).each { |target| @bodies << [body, target] }
|
|
125
|
+
end
|
|
126
|
+
@heredocs.sort_by!(&:first)
|
|
127
|
+
@bodies.each { |body, target| add_rows(body, target) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def collect(node, parent, local_scope)
|
|
131
|
+
heredoc = RebindingBlockCallees.heredoc_range(node)
|
|
132
|
+
@heredocs << heredoc if heredoc
|
|
133
|
+
note(node, parent, local_scope)
|
|
134
|
+
# A `def` opens a new local-variable scope; blocks and lambdas share the enclosing one. A class body is
|
|
135
|
+
# read as part of the scope around it, which only widens the set a `binding` there may reach.
|
|
136
|
+
inner = node.is_a?(Prism::DefNode) ? node : local_scope
|
|
137
|
+
node.compact_child_nodes.each { |child| collect(child, node, inner) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def note(node, parent, local_scope)
|
|
141
|
+
case node
|
|
142
|
+
when Prism::CallNode then note_call(node, parent, local_scope)
|
|
143
|
+
when Prism::LambdaNode then note_value(node, node, parent, local_scope)
|
|
144
|
+
when Prism::LocalVariableReadNode
|
|
145
|
+
@local_reads[node.name] << [node, parent] unless parent.is_a?(Prism::BlockArgumentNode)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def note_call(node, parent, local_scope)
|
|
150
|
+
block = node.block
|
|
151
|
+
body = RebindingBlockCallees.block_body(block)
|
|
152
|
+
@bodies << [body, node] if body
|
|
153
|
+
if block.is_a?(Prism::BlockArgumentNode) && block.expression.is_a?(Prism::LocalVariableReadNode)
|
|
154
|
+
@block_passes[block.expression.name] << node
|
|
155
|
+
end
|
|
156
|
+
value = RebindingBlockCallees.proc_literal(node)
|
|
157
|
+
note_value(node, value, parent, local_scope) if value
|
|
158
|
+
target = RebindingBlockCallees.send_target(node)
|
|
159
|
+
@scopes_reading_locals << local_scope if target.nil? || BINDING_CALLS.include?(target)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# `literal` is the proc expression and `body` its block. Passed with `&`, it is that call's block.
|
|
163
|
+
def note_value(literal, body, parent, local_scope)
|
|
164
|
+
return if parent.is_a?(Prism::BlockArgumentNode) || RebindingBlockCallees.plain_read?(literal, parent)
|
|
165
|
+
|
|
166
|
+
@proc_values << [body, parent, local_scope]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The calls a proc value assigned to a local reaches as `&local`, or {UNKNOWN}.
|
|
170
|
+
def value_targets(parent, local_scope)
|
|
171
|
+
return [UNKNOWN] if @scopes_reading_locals.include?(local_scope)
|
|
172
|
+
return [UNKNOWN] unless parent.is_a?(Prism::LocalVariableWriteNode)
|
|
173
|
+
|
|
174
|
+
name = parent.name
|
|
175
|
+
followed = @local_reads[name].all? do |read, read_parent|
|
|
176
|
+
RebindingBlockCallees.plain_read?(read, read_parent)
|
|
177
|
+
end
|
|
178
|
+
followed ? @block_passes[name] : [UNKNOWN]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The block's own row, plus one over the body of each heredoc that opens inside it.
|
|
182
|
+
def add_rows(body, target)
|
|
183
|
+
from = body.location.start_offset
|
|
184
|
+
to = body.location.end_offset
|
|
185
|
+
@rows << [from, to, target]
|
|
186
|
+
index = @heredocs.bsearch_index { |(opening, _start, _stop)| opening >= from }
|
|
187
|
+
return if index.nil?
|
|
188
|
+
|
|
189
|
+
while index < @heredocs.size && @heredocs[index].first < to
|
|
190
|
+
_opening, start, stop = @heredocs[index]
|
|
191
|
+
@rows << [start, stop, target]
|
|
192
|
+
index += 1
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
module_function
|
|
198
|
+
|
|
199
|
+
# Does `call_node` sit inside a block that some project definition of its callee may run under another `self`?
|
|
200
|
+
def inside_rebound_block?(call_node, scope, sites, scope_index = nil)
|
|
201
|
+
sites.enclosing(call_node).any? do |block_call|
|
|
202
|
+
block_call == Sites::UNKNOWN || callee_may_rebind?(block_call, scope, sites, scope_index)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# `[opening, body start, end]` offsets of a heredoc `node` opens, or nil.
|
|
207
|
+
def heredoc_range(node)
|
|
208
|
+
opening = node.opening_loc if node.respond_to?(:opening_loc)
|
|
209
|
+
closing = node.closing_loc if node.respond_to?(:closing_loc)
|
|
210
|
+
return nil unless opening&.slice&.start_with?("<<") && closing
|
|
211
|
+
|
|
212
|
+
start = case node
|
|
213
|
+
when Prism::StringNode, Prism::XStringNode then node.content_loc.start_offset
|
|
214
|
+
when Prism::InterpolatedStringNode, Prism::InterpolatedXStringNode
|
|
215
|
+
node.parts.first&.location&.start_offset
|
|
216
|
+
end
|
|
217
|
+
[opening.start_offset, start || closing.start_offset, closing.end_offset]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The node whose span is the block `call` passes: its literal block, or a lambda / proc literal passed with `&`.
|
|
221
|
+
def block_body(block)
|
|
222
|
+
case block
|
|
223
|
+
when Prism::BlockNode then block
|
|
224
|
+
when Prism::BlockArgumentNode then proc_literal(block.expression)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def proc_literal(node)
|
|
229
|
+
case node
|
|
230
|
+
when Prism::LambdaNode then node
|
|
231
|
+
when Prism::CallNode
|
|
232
|
+
node.block if node.block.is_a?(Prism::BlockNode) && proc_literal_call?(node)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def proc_literal_call?(node)
|
|
237
|
+
return PROC_LITERAL_CALLS.include?(node.name) if node.receiver.nil?
|
|
238
|
+
|
|
239
|
+
node.name == :new && node.receiver.is_a?(Prism::ConstantReadNode) && node.receiver.name == :Proc
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def callee_may_rebind?(block_call, scope, sites, scope_index = nil)
|
|
243
|
+
name, receiver = Dispatch.dispatch(block_call)
|
|
244
|
+
return true if name.nil? || REBINDING_CORE_CALLS.include?(name)
|
|
245
|
+
return true if Dispatch.callable_call?(name, receiver, scope, scope_index)
|
|
246
|
+
return true if answer(name, scope, sites)
|
|
247
|
+
# Ruby reaches `method_missing` only for a name the receiver lacks.
|
|
248
|
+
return false if Dispatch.resolves_on_receiver?(receiver, name, scope, scope_index)
|
|
249
|
+
|
|
250
|
+
answer(:method_missing, scope, sites)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def answer(name, scope, sites)
|
|
254
|
+
answers = sites.answers
|
|
255
|
+
return answers[name] if answers.key?(name)
|
|
256
|
+
|
|
257
|
+
answers[name] = project_may_rebind?(name, scope, sites)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The call's own name, or the literal name a `send` family call dispatches to (nil when it is not one).
|
|
261
|
+
def send_target(call)
|
|
262
|
+
return call.name unless SEND_CALLS.include?(call.name)
|
|
263
|
+
|
|
264
|
+
literal_name(call.arguments&.arguments&.first)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def literal_name(first)
|
|
268
|
+
case first
|
|
269
|
+
when Prism::SymbolNode, Prism::StringNode
|
|
270
|
+
text = first.unescaped
|
|
271
|
+
text.to_sym if text.valid_encoding?
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Reads every project table for `name`, so it records a `defines:<name>` edge whichever way it answers (a file
|
|
276
|
+
# that starts or stops defining the name re-checks this one), plus each read body's own method edge through the
|
|
277
|
+
# scope accessors (an edit of that body re-checks this one).
|
|
278
|
+
def project_may_rebind?(name, scope, sites)
|
|
279
|
+
DependencyRecorder.read_missing(:defines, name) if DependencyRecorder.active?
|
|
280
|
+
return true if patched_anywhere?(name, scope)
|
|
281
|
+
|
|
282
|
+
slots = Set.new
|
|
283
|
+
readers = {
|
|
284
|
+
[scope.discovered_def_nodes, :instance] => ->(owner) { instance_def(scope, owner, name) },
|
|
285
|
+
[scope.discovered_singleton_def_nodes, :singleton] => ->(owner) { scope.singleton_def_for(owner, name) }
|
|
286
|
+
}
|
|
287
|
+
readers.each do |(tables, side), reader|
|
|
288
|
+
tables.each do |owner, table|
|
|
289
|
+
next unless table.key?(name)
|
|
290
|
+
|
|
291
|
+
slots << [owner, side]
|
|
292
|
+
node = reader.call(owner)
|
|
293
|
+
return true if !node.is_a?(Prism::DefNode) || def_may_rebind?(node)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
unread_definition?(sites.def_rows(scope)[name], slots) || bodiless_definition?(name, scope)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def instance_def(scope, owner, name)
|
|
300
|
+
return scope.top_level_def_for(name) if owner == Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
|
|
301
|
+
|
|
302
|
+
scope.user_def_for(owner, name)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# A def table keeps one body per class and side, so a second `def` of the name for the same slot (in a
|
|
306
|
+
# sibling file, whose order is not load order, or in the other arm of an `if`) is one the tables never
|
|
307
|
+
# showed, and so is a `def` whose class and side the tables hold no slot for. Every `def` outside a deferred
|
|
308
|
+
# body has a named row in the deferred-range table; a `module_function` row stands for both sides. A row with
|
|
309
|
+
# no side (a `def` inside a block, a `define_singleton_method` name) counts under a nil side, which no slot has.
|
|
310
|
+
def unread_definition?(rows, slots)
|
|
311
|
+
return false if rows.nil?
|
|
312
|
+
|
|
313
|
+
rows.any? { |slot, count| count > 1 || !slots.include?(slot) }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def def_row_counts(scope)
|
|
317
|
+
counts = Hash.new { |hash, name| hash[name] = Hash.new(0) }
|
|
318
|
+
scope.discovered_deferred_ranges.each_value do |list|
|
|
319
|
+
list.each do |(_start, _stop, name, kind, owner)|
|
|
320
|
+
next if name.nil?
|
|
321
|
+
|
|
322
|
+
key = owner || Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
|
|
323
|
+
sides = kind == :both ? %i[instance singleton] : [kind]
|
|
324
|
+
sides.each { |side| counts[name][[key, side]] += 1 }
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
counts.default_proc = nil
|
|
328
|
+
counts
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# A name some class gives a method with no `def` body Rigor holds (`attr_*`, `alias_method`, `define_method`).
|
|
332
|
+
def bodiless_definition?(name, scope)
|
|
333
|
+
scope.discovered_methods.any? do |owner, table|
|
|
334
|
+
table.key?(name) &&
|
|
335
|
+
!scope.discovered_def_nodes[owner]&.key?(name) &&
|
|
336
|
+
!scope.discovered_singleton_def_nodes[owner]&.key?(name)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def patched_anywhere?(name, scope)
|
|
341
|
+
patched = scope.environment&.project_patched_methods
|
|
342
|
+
!patched.nil? && patched.by_key.any? { |(_owner, patched_name, _kind), _entry| patched_name == name }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def def_may_rebind?(def_node)
|
|
346
|
+
return false if def_node.body.nil?
|
|
347
|
+
|
|
348
|
+
block = def_node.parameters&.block
|
|
349
|
+
escapes?(def_node.body, nil, block&.name)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# `block_name` is the def's named `&` parameter, or nil.
|
|
353
|
+
def escapes?(node, parent, block_name)
|
|
354
|
+
return true if hands_block_on?(node, parent, block_name)
|
|
355
|
+
|
|
356
|
+
node.compact_child_nodes.any? { |child| escapes?(child, node, block_name) }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def hands_block_on?(node, parent, block_name)
|
|
360
|
+
case node
|
|
361
|
+
when Prism::ForwardingSuperNode then !node.block.is_a?(Prism::BlockNode)
|
|
362
|
+
when Prism::SuperNode then node.block.nil?
|
|
363
|
+
when Prism::BlockArgumentNode then node.expression.nil?
|
|
364
|
+
when Prism::ForwardingArgumentsNode then true
|
|
365
|
+
when Prism::LocalVariableReadNode then node.name == block_name && !plain_read?(node, parent)
|
|
366
|
+
when Prism::CallNode then reaches_locals?(node)
|
|
367
|
+
else false
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Issue #2186 — a call that can reach the block local by name, on any receiver and through `send`:
|
|
372
|
+
# `Kernel.eval("module_eval(&b)")`, `send(:binding).local_variable_get(:b)`, `instance_eval("module_eval(&b)")`.
|
|
373
|
+
# A `send` whose method name is not a literal may be any of them.
|
|
374
|
+
def reaches_locals?(call)
|
|
375
|
+
name = send_target(call)
|
|
376
|
+
return true if name.nil? || BINDING_CALLS.include?(name)
|
|
377
|
+
|
|
378
|
+
STRING_EVAL_CALLS.include?(name) && !call.arguments.nil?
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def plain_read?(node, parent)
|
|
382
|
+
case parent
|
|
383
|
+
when Prism::CallNode then parent.receiver.equal?(node) && PLAIN_PROC_CALLS.include?(parent.name)
|
|
384
|
+
when Prism::AndNode then parent.left.equal?(node)
|
|
385
|
+
when *PREDICATE_PARENTS then parent.predicate.equal?(node)
|
|
386
|
+
else false
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
require_relative "rebinding_block_callees/dispatch"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
5
|
require_relative "rule_ids"
|
|
6
|
+
require_relative "../dependency_recorder"
|
|
6
7
|
require_relative "../diagnostic"
|
|
7
8
|
require_relative "../../inference/version_guard"
|
|
8
9
|
require_relative "../../source/constant_path"
|
|
@@ -243,7 +244,13 @@ module Rigor
|
|
|
243
244
|
|
|
244
245
|
# Whether a project or `pre_eval:` file defines a method called `name` anywhere — on any class, on either
|
|
245
246
|
# side, or at the top level. Asked once per name per file.
|
|
247
|
+
#
|
|
248
|
+
# Issue #2133 — the answer reads every project file's tables whole, so it records a `defines:<name>` edge
|
|
249
|
+
# whichever way it answers, as {Inference::BlockCallTiming.project_defines_anywhere?} does: an edit that makes
|
|
250
|
+
# a file start or stop defining the name re-checks the call. A `pre_eval:` patch moves the snapshot's own
|
|
251
|
+
# fingerprint.
|
|
246
252
|
def project_defines?(scope, name)
|
|
253
|
+
Analysis::DependencyRecorder.read_missing(:defines, name) if Analysis::DependencyRecorder.active?
|
|
247
254
|
return true if scope.discovered_methods.any? { |_owner, table| table.key?(name) }
|
|
248
255
|
return true if scope.discovered_def_nodes.any? { |_owner, table| table.key?(name) }
|
|
249
256
|
|
|
@@ -53,6 +53,24 @@ module Rigor
|
|
|
53
53
|
# `else`-only.
|
|
54
54
|
DEFENSIVE_EXIT_CALLS = %i[raise fail throw abort exit].freeze
|
|
55
55
|
|
|
56
|
+
# Issue #2071 — core classes whose idiomatic stand-ins are not their subclasses: a `StringIO` answers the
|
|
57
|
+
# IO protocol without being an `IO`, and a `Tempfile` delegates to a `File` without being one (or an `IO`).
|
|
58
|
+
# A subject Rigor types as one of these classes (`$stdout` by idiom, ADR-117) may hold such a stand-in,
|
|
59
|
+
# which no `when IO` / `in File` matches, so the `else` still runs for it.
|
|
60
|
+
DUCK_STAND_INS = {
|
|
61
|
+
"IO" => %w[StringIO Tempfile].freeze,
|
|
62
|
+
"File" => %w[Tempfile].freeze
|
|
63
|
+
}.freeze
|
|
64
|
+
|
|
65
|
+
# Each stand-in's ancestors as Ruby 4.0.5 has them (`StringIO.ancestors`, `Tempfile.ancestors` without the
|
|
66
|
+
# anonymous `DelegateClass` entries): a clause naming one matches the stand-in. Written out rather than read
|
|
67
|
+
# from RBS, which declares `class Tempfile < File`; a `Tempfile` is a `Delegator`, so neither `Object` nor
|
|
68
|
+
# `Kernel` matches it.
|
|
69
|
+
STAND_IN_ANCESTORS = {
|
|
70
|
+
"StringIO" => %w[StringIO Enumerable Object Kernel BasicObject].freeze,
|
|
71
|
+
"Tempfile" => %w[Tempfile Delegator BasicObject].freeze
|
|
72
|
+
}.freeze
|
|
73
|
+
|
|
56
74
|
# @!attribute kind
|
|
57
75
|
# @return `:disjoint` (this clause's type is disjoint
|
|
58
76
|
# from the still-possible subject), `:prior_exhaustion` (an
|
|
@@ -137,9 +155,42 @@ module Rigor
|
|
|
137
155
|
end
|
|
138
156
|
coverage.record(clause)
|
|
139
157
|
end
|
|
158
|
+
return if stand_in_reaches_else?(node, entry_type)
|
|
159
|
+
|
|
140
160
|
collect_else(node.else_clause, subject.name, keyword)
|
|
141
161
|
end
|
|
142
162
|
|
|
163
|
+
# Issue #2071 — whether the subject, as the `case` binds it, has a `Nominal` member of a {DUCK_STAND_INS}
|
|
164
|
+
# class with a stand-in no clause names an ancestor of ({STAND_IN_ANCESTORS}). `io = STDOUT; case io when IO
|
|
165
|
+
# then … else … end` runs the `else` for a `StringIO`, which the type `IO` stands for in idiomatic Ruby; a
|
|
166
|
+
# literal subject is a `Constant`, not a `Nominal`, and keeps its verdict. Asked of the `else` only: a dead
|
|
167
|
+
# `when` has its own evidence rules (#1429, #1485).
|
|
168
|
+
def stand_in_reaches_else?(node, entry_type)
|
|
169
|
+
members = entry_type.is_a?(Type::Union) ? entry_type.members : [entry_type]
|
|
170
|
+
stand_ins = members.flat_map do |member|
|
|
171
|
+
member.is_a?(Type::Nominal) ? DUCK_STAND_INS.fetch(member.class_name.delete_prefix("::"), []) : []
|
|
172
|
+
end
|
|
173
|
+
return false if stand_ins.empty?
|
|
174
|
+
|
|
175
|
+
named = clause_class_names(node)
|
|
176
|
+
stand_ins.any? { |stand_in| named.none? { |name| STAND_IN_ANCESTORS.fetch(stand_in).include?(name) } }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The classes the clauses name as bare constants (`when A, B`, `in A`, `in A => x`).
|
|
180
|
+
def clause_class_names(node)
|
|
181
|
+
node.conditions.flat_map do |clause|
|
|
182
|
+
patterns = clause.is_a?(Prism::WhenNode) ? clause.conditions : [bare_pattern(clause.pattern)]
|
|
183
|
+
patterns.filter_map do |pattern|
|
|
184
|
+
entry = @scope_index[pattern]
|
|
185
|
+
entry && Inference::Narrowing.lexical_class_name(pattern, entry)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def bare_pattern(pattern)
|
|
191
|
+
pattern.is_a?(Prism::CapturePatternNode) ? pattern.value : pattern
|
|
192
|
+
end
|
|
193
|
+
|
|
143
194
|
def entry_subject_type(case_node, subject_name)
|
|
144
195
|
scope = @scope_index[case_node]
|
|
145
196
|
scope&.local(subject_name)
|