rigortype 0.4.3 → 0.4.5
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 +5 -5
- data/data/core_overlay/ostruct.rbs +40 -0
- data/docs/handbook/11-sig-gen.md +104 -15
- data/docs/manual/02-cli-reference.md +43 -12
- data/docs/manual/03-configuration.md +20 -7
- data/docs/manual/04-diagnostics.md +56 -3
- data/docs/manual/05-inspecting-types.md +2 -2
- data/docs/manual/06-baseline.md +1 -1
- data/docs/manual/07-plugins.md +3 -2
- data/docs/manual/08-skills.md +7 -0
- data/docs/manual/10-mcp-server.md +40 -4
- data/docs/manual/18-removing-dead-code.md +23 -2
- data/docs/manual/plugins/rigor-rails-routes.md +2 -2
- data/docs/manual/plugins/rigor-rspec.md +101 -1
- data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
- data/exe/rigor +12 -17
- data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
- data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +60 -1
- data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
- data/lib/rigor/analysis/check_rules.rb +274 -51
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/incremental.rb +61 -0
- data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
- data/lib/rigor/analysis/incremental_session.rb +270 -28
- data/lib/rigor/analysis/path_expansion.rb +3 -1
- data/lib/rigor/analysis/reachability/graph.rb +111 -14
- data/lib/rigor/analysis/reachability/scan.rb +305 -23
- data/lib/rigor/analysis/rule_catalog.rb +22 -4
- data/lib/rigor/analysis/run_cache_key.rb +31 -7
- data/lib/rigor/analysis/run_cache_probe.rb +5 -3
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
- data/lib/rigor/analysis/runner/pool_coordinator.rb +179 -31
- data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
- data/lib/rigor/analysis/runner.rb +102 -72
- data/lib/rigor/analysis/severity_stamp.rb +45 -1
- data/lib/rigor/analysis/worker_session.rb +69 -2
- data/lib/rigor/cache/descriptor.rb +79 -12
- data/lib/rigor/cache/engine_source.rb +3 -2
- data/lib/rigor/cache/incremental_snapshot.rb +39 -5
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +4 -3
- data/lib/rigor/cli/command.rb +3 -1
- data/lib/rigor/cli/docs_command.rb +3 -2
- data/lib/rigor/cli/doctor_command.rb +1 -1
- data/lib/rigor/cli/explain_command.rb +3 -2
- data/lib/rigor/cli/mcp_command.rb +1 -1
- data/lib/rigor/cli/plugin_command.rb +2 -1
- data/lib/rigor/cli/plugins_command.rb +2 -1
- data/lib/rigor/cli/sig_gen_command.rb +32 -11
- data/lib/rigor/cli/skill_command.rb +2 -1
- data/lib/rigor/cli/skill_describe.rb +3 -2
- data/lib/rigor/cli/unused_command.rb +10 -7
- data/lib/rigor/cli.rb +3 -2
- data/lib/rigor/config_audit.rb +163 -22
- data/lib/rigor/configuration/dependencies.rb +19 -11
- data/lib/rigor/configuration/severity_profile.rb +20 -2
- data/lib/rigor/configuration.rb +29 -3
- data/lib/rigor/effects/signature_sources.rb +3 -1
- data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
- data/lib/rigor/environment/gated_signature_guard.rb +2 -1
- data/lib/rigor/environment/gated_signature_plan.rb +2 -1
- data/lib/rigor/environment/installed_gem_set.rb +2 -1
- data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
- data/lib/rigor/environment/rbs_loader.rb +67 -11
- data/lib/rigor/inference/block_call_timing.rb +12 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
- data/lib/rigor/inference/captured_locals.rb +67 -13
- data/lib/rigor/inference/clause_widening.rb +90 -0
- data/lib/rigor/inference/definer_resolution.rb +101 -25
- data/lib/rigor/inference/expression_typer.rb +94 -9
- data/lib/rigor/inference/guard_rebinding.rb +7 -2
- data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
- data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
- data/lib/rigor/inference/macro_block_self_type.rb +20 -6
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
- data/lib/rigor/inference/method_dispatcher.rb +67 -3
- data/lib/rigor/inference/module_function_state.rb +213 -51
- data/lib/rigor/inference/narrowing.rb +235 -75
- data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
- data/lib/rigor/inference/project_patched_methods.rb +168 -4
- data/lib/rigor/inference/project_patched_mixins.rb +162 -0
- data/lib/rigor/inference/project_patched_scanner.rb +51 -12
- data/lib/rigor/inference/project_patched_visibility.rb +214 -0
- data/lib/rigor/inference/project_seed_tables.rb +120 -0
- data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
- data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
- data/lib/rigor/inference/repeated_or_writes.rb +414 -1
- data/lib/rigor/inference/return_barrier.rb +16 -0
- data/lib/rigor/inference/scope_indexer.rb +1061 -122
- data/lib/rigor/inference/self_call_ivar_writes.rb +346 -0
- data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
- data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
- data/lib/rigor/inference/statement_evaluator.rb +512 -150
- data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
- data/lib/rigor/lens/anchor.rb +3 -3
- data/lib/rigor/lens/contract_reader.rb +60 -2
- data/lib/rigor/lens/document.rb +4 -3
- data/lib/rigor/lens/file_reader.rb +452 -73
- data/lib/rigor/lens/members.rb +81 -17
- data/lib/rigor/lens/run.rb +73 -4
- data/lib/rigor/lens.rb +2 -1
- data/lib/rigor/mcp/server.rb +63 -22
- data/lib/rigor/plugin/base.rb +3 -2
- data/lib/rigor/plugin/box_probe.rb +154 -7
- data/lib/rigor/plugin/io_boundary.rb +42 -6
- data/lib/rigor/plugin/isolation.rb +4 -3
- data/lib/rigor/project_environment.rb +6 -3
- data/lib/rigor/protection/discovery_seed.rb +9 -36
- data/lib/rigor/protection/mutation_cache.rb +8 -2
- data/lib/rigor/rooted_glob.rb +27 -0
- data/lib/rigor/scope/discovery_index.rb +5 -0
- data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
- data/lib/rigor/scope/resolution_chain.rb +40 -5
- data/lib/rigor/scope.rb +56 -8
- data/lib/rigor/sig_gen/alias_index.rb +2 -1
- data/lib/rigor/sig_gen/classification.rb +4 -1
- data/lib/rigor/sig_gen/generator.rb +167 -22
- data/lib/rigor/sig_gen/layout_index.rb +40 -1
- data/lib/rigor/sig_gen/method_candidate.rb +8 -0
- data/lib/rigor/sig_gen/observation_collector.rb +137 -11
- data/lib/rigor/sig_gen/path_mapper.rb +18 -2
- data/lib/rigor/sig_gen/renderer.rb +13 -155
- data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
- data/lib/rigor/sig_gen/write_reporter.rb +203 -0
- data/lib/rigor/sig_gen/write_result.rb +3 -1
- data/lib/rigor/sig_gen/writer.rb +50 -35
- data/lib/rigor/signature_path_audit.rb +4 -2
- data/lib/rigor/type/constant.rb +10 -1
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +57 -6
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
- data/sig/rigor/plugin/io_boundary.rbs +5 -2
- data/sig/rigor/rooted_glob.rbs +7 -0
- data/sig/rigor/scope.rbs +4 -3
- data/skills/rigor-doctor/references/01-checks.md +23 -11
- data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
- metadata +16 -1
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../source/literals"
|
|
6
|
+
require_relative "../source/node_children"
|
|
7
|
+
require_relative "module_function_state"
|
|
8
|
+
require_relative "project_patched_mixins"
|
|
9
|
+
|
|
10
|
+
module Rigor
|
|
11
|
+
module Inference
|
|
12
|
+
module ProjectPatchedScanner
|
|
13
|
+
# Issue #1708 — the visibility a `pre_eval:` file gives each `def` it writes in a class, module or `class <<`
|
|
14
|
+
# body, so a private patch does not answer a call with an explicit receiver. Ruby 4.0.5:
|
|
15
|
+
# `module Kernel; private def kp = 1; end; 1.kp` raises NoMethodError (private method 'kp' called for an
|
|
16
|
+
# instance of Integer), while a bare `kp` runs.
|
|
17
|
+
#
|
|
18
|
+
# The readings, over each body's direct statements in order:
|
|
19
|
+
#
|
|
20
|
+
# - a bare `private`, `protected`, `public` or `module_function` (receiverless or on `self`) sets the default
|
|
21
|
+
# for the `def`s after it in that body;
|
|
22
|
+
# - `private def x`, `protected def x`, `public def x`, `module_function def x` set that `def`'s, and
|
|
23
|
+
# `private_class_method def self.x` / `public_class_method def self.x` a singleton `def`'s;
|
|
24
|
+
# - a `def self.x` is public whatever the default says, as in Ruby;
|
|
25
|
+
# - a `def` nested in a statement (an `if`, a `begin`) takes the default, unless the statement holds a bare
|
|
26
|
+
# call of its own, which may or may not run: then it and every `def` after it are public, the reading that
|
|
27
|
+
# answers more calls.
|
|
28
|
+
#
|
|
29
|
+
# A `def` this does not reach (one in a block, a `rescue` clause, or a body nested in a conditional, a block or a
|
|
30
|
+
# method, which may not run) is public. A named call with Symbol arguments (`private :x`) is the scanner's to
|
|
31
|
+
# apply, since it may name a `def` of an earlier file ({.pending}).
|
|
32
|
+
module Visibility
|
|
33
|
+
# The receiverless calls that set an instance `def`'s visibility, and what they set. `module_function` makes
|
|
34
|
+
# the instance method private and adds a public singleton copy.
|
|
35
|
+
INSTANCE_CALLS = {
|
|
36
|
+
private: :private, protected: :protected, public: :public, module_function: :module_function
|
|
37
|
+
}.freeze
|
|
38
|
+
# The calls that set a singleton `def`'s visibility.
|
|
39
|
+
SINGLETON_CALLS = { private_class_method: :private, public_class_method: :public }.freeze
|
|
40
|
+
|
|
41
|
+
# The nodes a nested `def` does not take the body's default under: another body, or a block.
|
|
42
|
+
OPAQUE = [
|
|
43
|
+
Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode, Prism::DefNode, Prism::BlockNode,
|
|
44
|
+
Prism::LambdaNode
|
|
45
|
+
].freeze
|
|
46
|
+
private_constant :OPAQUE
|
|
47
|
+
|
|
48
|
+
# The bodies {.read_body} reads when they are a direct statement of a body it reads.
|
|
49
|
+
BODIES = [Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
50
|
+
private_constant :BODIES
|
|
51
|
+
|
|
52
|
+
# A named visibility call (`private :x`, `module_function :x`, `private_class_method :x`) in a body, which
|
|
53
|
+
# {ProjectPatchedScanner.resolve_aliases} applies to the binding its name has when it runs. `visibility` is
|
|
54
|
+
# `:private`, `:protected`, `:public` or `:module_function`.
|
|
55
|
+
Pending = Data.define(:class_name, :names, :kind, :visibility, :source_path, :source_line)
|
|
56
|
+
|
|
57
|
+
module_function
|
|
58
|
+
|
|
59
|
+
# The {Pending} change a named visibility call in a body makes, or nil. The `def`-argument form
|
|
60
|
+
# (`private def x`) is {.defs}'s; a computed name is skipped, leaving the method public.
|
|
61
|
+
def pending(node, qualified_prefix, in_singleton_class, source_path)
|
|
62
|
+
return nil if qualified_prefix.empty?
|
|
63
|
+
|
|
64
|
+
visibility, side = call(node)
|
|
65
|
+
return nil if visibility.nil? || node.arguments.nil?
|
|
66
|
+
|
|
67
|
+
names = node.arguments.arguments.filter_map { |arg| Source::Literals.symbol_or_string(arg) }
|
|
68
|
+
return nil if names.empty?
|
|
69
|
+
|
|
70
|
+
Pending.new(
|
|
71
|
+
class_name: qualified_prefix.join("::"), names: names,
|
|
72
|
+
kind: side == :singleton || in_singleton_class ? :singleton : :instance, visibility: visibility,
|
|
73
|
+
source_path: source_path, source_line: node.location&.start_line || 1
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The value {.defs} records for a call written as a direct statement of a body or of the file's top level, the
|
|
78
|
+
# only calls whose named visibility change ({.pending}) or mixin edge ({Mixins.pending}) the scanner applies to
|
|
79
|
+
# the body's class. A call nested in a conditional, a block, a method body or a `Class.new` may not run, or
|
|
80
|
+
# runs on another class, so it changes nothing: the reading that answers more calls.
|
|
81
|
+
STATEMENT = Object.new.freeze
|
|
82
|
+
|
|
83
|
+
# @return the visibility of each `def` (by identity) the file's bodies give one other than public:
|
|
84
|
+
# `:private`, `:protected` or `:module_function`; and {STATEMENT} for each call that is a direct statement,
|
|
85
|
+
# and for the include an idempotence guard runs ({Mixins.guarded}).
|
|
86
|
+
def defs(root)
|
|
87
|
+
found = {}.compare_by_identity
|
|
88
|
+
# The file's top level, and through {.read_body} every class, module or `class <<` body written as a direct
|
|
89
|
+
# statement of it or of another body so read: the bodies that run whenever the file loads. A body nested in
|
|
90
|
+
# a conditional, a block or a method may not run, so none of its calls or toggles is read and its `def`s
|
|
91
|
+
# are public.
|
|
92
|
+
read_body(root.statements, found, top_level: true) if root.is_a?(Prism::ProgramNode)
|
|
93
|
+
found
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Applies a {Pending} change to the binding each of its names has: `bound` maps `[class_name, name, kind]` to
|
|
97
|
+
# that binding and `homes` to the per-file table holding it, which is updated in place, so the change is not a
|
|
98
|
+
# second declaration. A name the class does not bind is one it inherits (`public :kp` in `class Integer` over
|
|
99
|
+
# `module Kernel; private def kp`), which Ruby copies onto the class with the new visibility: `overrides` gets
|
|
100
|
+
# `key => visibility` for {ProjectPatchedMethods#lookup_through_ancestors} to apply to the body it finds farther
|
|
101
|
+
# on. Yields each copy that binds (`module_function :x`'s singleton), at the call's line, for the caller to
|
|
102
|
+
# bind.
|
|
103
|
+
def apply(pending, bound, homes, overrides)
|
|
104
|
+
pending.names.each do |name|
|
|
105
|
+
key = [pending.class_name, name, pending.kind]
|
|
106
|
+
entry = bound[key]
|
|
107
|
+
next overrides[key] = pending.visibility if entry.nil?
|
|
108
|
+
|
|
109
|
+
changed, *copies = applied(entry, pending.visibility)
|
|
110
|
+
bound[key] = homes[key][key] = changed
|
|
111
|
+
copies.each { |copy| yield copy.with(source_path: pending.source_path, source_line: pending.source_line) }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# `entry` as `visibility` leaves it, then any copy that binds: `module_function` makes an instance method
|
|
116
|
+
# private and binds a public singleton copy, as Ruby does.
|
|
117
|
+
def applied(entry, visibility)
|
|
118
|
+
return [entry.with(visibility: visibility)] unless visibility == :module_function
|
|
119
|
+
|
|
120
|
+
copies = [entry.with(visibility: :private)]
|
|
121
|
+
copies << entry.with(kind: :singleton, visibility: :public) if entry.kind == :instance
|
|
122
|
+
copies
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The visibility call `node` is, as `[visibility, side]`, or nil: `side` is `:instance` for the
|
|
126
|
+
# {INSTANCE_CALLS} and `:singleton` for the {SINGLETON_CALLS}.
|
|
127
|
+
def call(node)
|
|
128
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
129
|
+
return nil unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
|
|
130
|
+
|
|
131
|
+
if (visibility = INSTANCE_CALLS[node.name])
|
|
132
|
+
[visibility, :instance]
|
|
133
|
+
elsif (visibility = SINGLETON_CALLS[node.name])
|
|
134
|
+
[visibility, :singleton]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The include an idempotence guard runs (`X.include(M) unless X.include?(M)`) is read as a direct statement.
|
|
139
|
+
def mark_guarded(stmt, found, top_level)
|
|
140
|
+
guarded = Mixins.guarded(stmt, top_level)
|
|
141
|
+
found[guarded] = STATEMENT if guarded
|
|
142
|
+
end
|
|
143
|
+
private_class_method :mark_guarded
|
|
144
|
+
|
|
145
|
+
# `top_level` is true for the file's own statements only; a body it reads is a class or module body.
|
|
146
|
+
def read_body(body, found, top_level: false) # rubocop:disable Metrics/PerceivedComplexity
|
|
147
|
+
default = :public
|
|
148
|
+
direct_statements(body).each do |stmt|
|
|
149
|
+
found[stmt] = STATEMENT if stmt.is_a?(Prism::CallNode)
|
|
150
|
+
visibility, side = call(stmt)
|
|
151
|
+
if visibility && ModuleFunctionState.bare?(stmt)
|
|
152
|
+
default = visibility if side == :instance
|
|
153
|
+
elsif visibility
|
|
154
|
+
mark_arguments(stmt, visibility, side, found)
|
|
155
|
+
elsif stmt.is_a?(Prism::DefNode)
|
|
156
|
+
mark(stmt, default, found) if stmt.receiver.nil?
|
|
157
|
+
elsif BODIES.any? { |kind| stmt.is_a?(kind) }
|
|
158
|
+
read_body(stmt.body, found)
|
|
159
|
+
else
|
|
160
|
+
mark_guarded(stmt, found, top_level)
|
|
161
|
+
default = :public if bare_call_nested?(stmt)
|
|
162
|
+
mark_nested(stmt, default, found)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
private_class_method :read_body
|
|
167
|
+
|
|
168
|
+
def direct_statements(body)
|
|
169
|
+
body = body.statements if body.is_a?(Prism::BeginNode)
|
|
170
|
+
body.is_a?(Prism::StatementsNode) ? body.body : []
|
|
171
|
+
end
|
|
172
|
+
private_class_method :direct_statements
|
|
173
|
+
|
|
174
|
+
def mark_arguments(call, visibility, side, found)
|
|
175
|
+
call.arguments&.arguments&.each do |arg|
|
|
176
|
+
next unless arg.is_a?(Prism::DefNode)
|
|
177
|
+
next unless (side == :singleton) == arg.receiver.is_a?(Prism::SelfNode)
|
|
178
|
+
|
|
179
|
+
mark(arg, visibility, found)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
private_class_method :mark_arguments
|
|
183
|
+
|
|
184
|
+
def mark(def_node, visibility, found)
|
|
185
|
+
found[def_node] = visibility unless visibility == :public
|
|
186
|
+
end
|
|
187
|
+
private_class_method :mark
|
|
188
|
+
|
|
189
|
+
def mark_nested(node, default, found)
|
|
190
|
+
return unless node.is_a?(Prism::Node)
|
|
191
|
+
return if OPAQUE.any? { |kind| node.is_a?(kind) } && !node.is_a?(Prism::DefNode)
|
|
192
|
+
|
|
193
|
+
if node.is_a?(Prism::DefNode)
|
|
194
|
+
mark(node, default, found) if node.receiver.nil?
|
|
195
|
+
return
|
|
196
|
+
end
|
|
197
|
+
node.rigor_each_child { |child| mark_nested(child, default, found) }
|
|
198
|
+
end
|
|
199
|
+
private_class_method :mark_nested
|
|
200
|
+
|
|
201
|
+
def bare_call_nested?(node)
|
|
202
|
+
return false unless node.is_a?(Prism::Node)
|
|
203
|
+
return false if OPAQUE.any? { |kind| node.is_a?(kind) }
|
|
204
|
+
|
|
205
|
+
visibility, = call(node)
|
|
206
|
+
return true if visibility && ModuleFunctionState.bare?(node)
|
|
207
|
+
|
|
208
|
+
node.compact_child_nodes.any? { |child| bare_call_nested?(child) }
|
|
209
|
+
end
|
|
210
|
+
private_class_method :bare_call_nested?
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "scope_indexer"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Inference
|
|
7
|
+
# Issue #1614 — the cross-file discovery seed the paths that type a project WITHOUT a `check` run hand their
|
|
8
|
+
# per-file scopes: {Protection::DiscoverySeed} (the protection scan, `rigor coverage`) and
|
|
9
|
+
# {ParameterInferenceCollector} (the ADR-67 call-site pre-pass, which `check` itself runs under
|
|
10
|
+
# `parameter_inference:`). Each used to keep its own list of tables, and each dropped some that
|
|
11
|
+
# `Analysis::Runner#project_scope_seed_tables` carries, so a cross-file constant read `30` under `check` and
|
|
12
|
+
# `Dynamic` under coverage, and a parameter whose call sites pass it went uninferred.
|
|
13
|
+
#
|
|
14
|
+
# This module is the one list both paths read. {Analysis::Runner#project_scope_seed_tables} stays the
|
|
15
|
+
# reference; `spec/rigor/inference/project_seed_tables_spec.rb` compares the two over a project that fills
|
|
16
|
+
# every table, and requires each table the runner seeds and this one does not to be in {NOT_SEEDED}, with its
|
|
17
|
+
# reason.
|
|
18
|
+
module ProjectSeedTables
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# The def-index slots that seed a per-file scope as they are, each with the {Scope::DiscoveryIndex} member it
|
|
22
|
+
# fills.
|
|
23
|
+
SLOTS = {
|
|
24
|
+
def_nodes: :discovered_def_nodes, def_nestings: :discovered_def_nestings,
|
|
25
|
+
singleton_def_nodes: :discovered_singleton_def_nodes, def_sources: :discovered_def_sources,
|
|
26
|
+
singleton_def_sources: :discovered_singleton_def_sources, superclasses: :discovered_superclasses,
|
|
27
|
+
header_nestings: :discovered_header_nestings, includes: :discovered_includes,
|
|
28
|
+
prepends: :discovered_prepends, extends: :discovered_extends, unpositioned_mixins: :unpositioned_mixins,
|
|
29
|
+
class_sources: :discovered_class_sources, method_visibilities: :discovered_method_visibilities,
|
|
30
|
+
methods: :discovered_methods, parameter_envelopes: :discovered_parameter_envelopes,
|
|
31
|
+
data_member_layouts: :data_member_layouts, struct_member_layouts: :struct_member_layouts,
|
|
32
|
+
deferred_ranges: :discovered_deferred_ranges, refinements: :discovered_refinements,
|
|
33
|
+
global_write_census: :discovered_global_write_census
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# The tables `Runner#project_scope_seed_tables` can seed that {.from_index} leaves out, and why. Parity is over
|
|
37
|
+
# the table LIST: the runner's opt-in additions to a table's content are not reproduced here.
|
|
38
|
+
# `discovered_refinements` is the project's alone (the runner unions in the refinements of gems under
|
|
39
|
+
# `dependencies.source_inference:`), and no `discovery_seed:` base is merged in (the runner can start from one).
|
|
40
|
+
NOT_SEEDED = {
|
|
41
|
+
run_generation: "the identity token of a check run's return memo; these paths run no check",
|
|
42
|
+
constant_sources: "read only by dependency recording, which only an incremental check run turns on",
|
|
43
|
+
param_inferred_types: "the parameter-inference output itself; each caller adds its own " \
|
|
44
|
+
"(DiscoverySeed.build, coverage under parameter_inference:, the collector's last round)"
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# The seed tables over `paths`, from one parse of each file.
|
|
48
|
+
def for_paths(paths)
|
|
49
|
+
from_index(ScopeIndexer.discovered_project_index_for_paths(paths))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Maps a `{ classes:, def_index: }` discovery index onto {Scope::DiscoveryIndex} member names. Empty tables are
|
|
53
|
+
# dropped so an empty seed stays `{}`; a paired member and its sibling are dropped only together.
|
|
54
|
+
#
|
|
55
|
+
# `in_source_constants` is the project's literal constant table alone: the `pre_eval:` publication a `check` run
|
|
56
|
+
# joins to it types each listed file's writes against the RBS environment, which these paths do not build first.
|
|
57
|
+
def from_index(index)
|
|
58
|
+
def_index = index.fetch(:def_index)
|
|
59
|
+
tables = { discovered_classes: index.fetch(:classes) }
|
|
60
|
+
SLOTS.each { |slot, member| tables[member] = def_index.fetch(slot) }
|
|
61
|
+
tables.merge!(constant_tables(def_index.fetch(:constant_values), def_index.fetch(:constant_writes)))
|
|
62
|
+
tables.merge!(def_index.fetch(:siblings))
|
|
63
|
+
Scope::DiscoveryIndex.compact_pairs(
|
|
64
|
+
tables.reject { |name, table| !Scope::DiscoveryIndex.paired?(name) && (table.nil? || table.empty?) }
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def constant_tables(values, writes)
|
|
69
|
+
{
|
|
70
|
+
in_source_constants: values,
|
|
71
|
+
published_constant_names: published_constant_names(values),
|
|
72
|
+
constant_writers: constant_writers(writes),
|
|
73
|
+
constant_shadowers: constant_shadowers(writes)
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Issue #644 — the last segments of the published constant table, the run-wide half of
|
|
78
|
+
# `Scope#published_constant?`.
|
|
79
|
+
def published_constant_names(values)
|
|
80
|
+
values.keys.to_set { |name| name.split("::").last }.freeze
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Issue #617 — the censused names that bind, grouped by last segment (`Scope#bound_constant_names`). A name some
|
|
84
|
+
# write other than a memo `||=` touches always binds. A memo-only name binds once memos of its segment span two
|
|
85
|
+
# files: either may load first and set the constant the other then reads, so only a memo no other file shares
|
|
86
|
+
# keeps the idiom's reading. Files are counted expanded, because discovery can walk one file under two
|
|
87
|
+
# spellings (`lib/b.rb`, `./lib/b.rb`).
|
|
88
|
+
def constant_writers(writes)
|
|
89
|
+
memo_files = memo_files_by_segment(writes)
|
|
90
|
+
writes.each_with_object({}) do |(name, by_path), index|
|
|
91
|
+
segment = name.split("::").last
|
|
92
|
+
next if memo_only?(by_path) && memo_files.fetch(segment).size < 2
|
|
93
|
+
|
|
94
|
+
(index[segment] ||= []) << name
|
|
95
|
+
end.each_value(&:freeze).freeze
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Issue #1290 — the censused names some write other than a memo `||=` assigns, grouped by last segment
|
|
99
|
+
# (`Scope#shadowing_constant_names`): the ones that exist whether or not an outer constant of the name does, and
|
|
100
|
+
# so stop the lexical ladder above it. A memo-only name is left out however many files memoize it, unlike in
|
|
101
|
+
# {.constant_writers}: `X ||= v` resolves `X` through the very lookup whose lower rung the stop would replace,
|
|
102
|
+
# so wherever that rung answers, the memo finds it and never creates the name.
|
|
103
|
+
def constant_shadowers(writes)
|
|
104
|
+
writes.each_with_object({}) do |(name, by_path), index|
|
|
105
|
+
(index[name.split("::").last] ||= []) << name unless memo_only?(by_path)
|
|
106
|
+
end.each_value(&:freeze).freeze
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def memo_files_by_segment(writes)
|
|
110
|
+
writes.each_with_object({}) do |(name, by_path), files|
|
|
111
|
+
set = (files[name.split("::").last] ||= Set.new)
|
|
112
|
+
by_path.each { |path, descriptor| set << File.absolute_path(path) if memo_descriptor?(descriptor) }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def memo_only?(by_path) = by_path.each_value.all? { |descriptor| memo_descriptor?(descriptor) }
|
|
117
|
+
def memo_descriptor?(descriptor) = descriptor == ScopeIndexer::CONSTANT_MEMO
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Inference
|
|
5
|
+
module RefinedDispatch
|
|
6
|
+
# Issue #1979 — whether a class whose superclass the tables do not know is `class K < <expr>` with `<expr>` a
|
|
7
|
+
# core constructor call (`Data.define(...)`, `Struct.new(...)`, `Class.new(<constant>)`) that cannot answer a
|
|
8
|
+
# given class or one of its subclasses, so a subclass walk may leave it out (`OwnMethod.each_subclass` with
|
|
9
|
+
# `possible_only:`).
|
|
10
|
+
#
|
|
11
|
+
# Each constant the expression names (the indexer's `[DiscoveryIndex::ENVELOPE_CONSTRUCTED_KIND, name]` entries)
|
|
12
|
+
# must be a class the environment knows that is neither the given class nor a subclass of it, and the project
|
|
13
|
+
# must not touch the name: no project class or module whose last segment is the name (Ruby resolves a constant
|
|
14
|
+
# through the enclosing classes' ancestors and mixins too, so a `Base::StandardError` a class inherits may be
|
|
15
|
+
# what `Class.new(StandardError)` names), no reopening, singleton `def`, refinement or method table keyed by
|
|
16
|
+
# the name itself, and no constant write of it. Any other shape answers false, as does a class a
|
|
17
|
+
# `K = Class.new(...)` write names.
|
|
18
|
+
module ConstructedClass
|
|
19
|
+
MEMO_KEY = :__rigor_constructed_class__
|
|
20
|
+
EMPTY = [].freeze
|
|
21
|
+
private_constant :MEMO_KEY, :EMPTY
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# True when `name` cannot be `class_name` or a subclass of it. Files `name`'s class edge and a `constant:`
|
|
26
|
+
# edge per named constant on every call; a class declaration appearing fires the walk's `subclass_of:*`.
|
|
27
|
+
def elsewhere?(scope, name, class_name)
|
|
28
|
+
table = memo(scope)
|
|
29
|
+
named = constructed_names(scope, name, table)
|
|
30
|
+
if Analysis::DependencyRecorder.active?
|
|
31
|
+
Scope::ResolutionChain.record_class(scope, name)
|
|
32
|
+
named.each { |constant| Analysis::DependencyRecorder.read_name(:constant, constant) }
|
|
33
|
+
end
|
|
34
|
+
return false if named.empty?
|
|
35
|
+
|
|
36
|
+
by_class = (table[:verdicts][class_name] ||= {})
|
|
37
|
+
return by_class[named] if by_class.key?(named)
|
|
38
|
+
|
|
39
|
+
by_class[named] = named.all? { |constant| core_class_apart?(scope, constant, class_name.to_s, table) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def core_class_apart?(scope, constant, class_name, table)
|
|
43
|
+
environment = scope.environment
|
|
44
|
+
return false unless environment&.class_known?(constant)
|
|
45
|
+
return false if project_name?(scope, constant, table)
|
|
46
|
+
return false unless scope.shadowing_constant_names(constant).empty?
|
|
47
|
+
|
|
48
|
+
ordering = environment.class_ordering(constant, class_name)
|
|
49
|
+
%i[disjoint superclass].include?(ordering) ||
|
|
50
|
+
(ordering == :unknown && !environment.class_known?(class_name))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Whether the project touches `constant` by name: a declared or reopened class or module whose last segment
|
|
54
|
+
# it is, or a top-level table keyed by it (a singleton `def Data.define`, methods, envelope entries or marks,
|
|
55
|
+
# mixins, a refinement).
|
|
56
|
+
def project_name?(scope, constant, table)
|
|
57
|
+
return true if declared_segments(scope, table).include?(constant)
|
|
58
|
+
|
|
59
|
+
discovery = scope.discovery
|
|
60
|
+
[discovery.discovered_classes, discovery.discovered_superclasses, discovery.discovered_includes,
|
|
61
|
+
discovery.discovered_prepends, discovery.discovered_extends, discovery.discovered_methods,
|
|
62
|
+
discovery.discovered_parameter_envelopes, discovery.discovered_refinements].any? do |by_name|
|
|
63
|
+
by_name.key?(constant)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The last segment of every class or module the project declares, reopenings included.
|
|
68
|
+
def declared_segments(scope, table)
|
|
69
|
+
table[:segments] ||= declared_names(scope).each_key.to_set { |key| key.to_s.split("::").last }.freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The constants `name`'s superclass expression names, or an empty Array; memoised per envelope bucket,
|
|
73
|
+
# which a file's own discovery shares with the run's seed for every class it does not declare.
|
|
74
|
+
def constructed_names(scope, name, table)
|
|
75
|
+
bucket = scope.discovered_parameter_envelopes[name]
|
|
76
|
+
return EMPTY if bucket.nil?
|
|
77
|
+
|
|
78
|
+
names = table[:names]
|
|
79
|
+
return names[bucket] if names.key?(bucket)
|
|
80
|
+
|
|
81
|
+
names[bucket] =
|
|
82
|
+
if bucket.key?(Scope::DiscoveryIndex::ENVELOPE_META_SUPERCLASS_MARK)
|
|
83
|
+
EMPTY
|
|
84
|
+
else
|
|
85
|
+
kind = Scope::DiscoveryIndex::ENVELOPE_CONSTRUCTED_KIND
|
|
86
|
+
bucket.each_key.filter_map { |key| key[1] if key.is_a?(Array) && key[0] == kind }.freeze
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The run-wide table of declared classes: the seeded `discovered_class_sources`, or a single-file run's
|
|
91
|
+
# `discovered_classes` where nothing seeded it.
|
|
92
|
+
def declared_names(scope)
|
|
93
|
+
sources = scope.discovery.discovered_class_sources
|
|
94
|
+
sources.empty? ? scope.discovery.discovered_classes : sources
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# One thread-local slot keyed on the run-generation token the runner seeds (or, unseeded, the file's merged
|
|
98
|
+
# `discovered_def_nodes`, as `ExpressionTyper` falls back) and the environment: every table read here is
|
|
99
|
+
# fixed for a run, so each answer is computed once a run, and a new run replaces the slot.
|
|
100
|
+
def memo(scope)
|
|
101
|
+
slot = Thread.current[MEMO_KEY]
|
|
102
|
+
generation = scope.run_generation || scope.discovered_def_nodes
|
|
103
|
+
environment = scope.environment
|
|
104
|
+
unless slot && slot[0].equal?(generation) && slot[1].equal?(environment)
|
|
105
|
+
slot = [generation, environment, { names: {}.compare_by_identity, verdicts: {} }]
|
|
106
|
+
Thread.current[MEMO_KEY] = slot
|
|
107
|
+
end
|
|
108
|
+
slot[2]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../builtins/cruby_definers"
|
|
4
|
+
require_relative "constructed_class"
|
|
4
5
|
|
|
5
6
|
module Rigor
|
|
6
7
|
module Inference
|
|
@@ -193,8 +194,7 @@ module Rigor
|
|
|
193
194
|
# A module some code `prepend`s to an object's singleton class (or `extend`s an object with in a method
|
|
194
195
|
# body), which puts it ahead of the object's class even where the class already includes it.
|
|
195
196
|
def object_prepended?(scope, name)
|
|
196
|
-
scope.
|
|
197
|
-
false
|
|
197
|
+
scope.parameter_envelopes_of(name).key?(Scope::DiscoveryIndex::ENVELOPE_OBJECT_EXTENDED_MARK)
|
|
198
198
|
end
|
|
199
199
|
|
|
200
200
|
# Is some in-effect row under a class no table declares by any spelling of it (the keys sharing its last
|
|
@@ -282,10 +282,16 @@ module Rigor
|
|
|
282
282
|
# declaration with that superclass appearing or vanishing fires (`IncrementalSession#negative_affected`).
|
|
283
283
|
# A class whose superclass the tables do not know may be a subclass of any class, so it joins the walk too
|
|
284
284
|
# (`subclass_of:*`, which any class declaration appearing or vanishing fires).
|
|
285
|
-
|
|
285
|
+
#
|
|
286
|
+
# Issue #1979 — with `possible_only:`, a class whose superclass expression is a core constructor call that
|
|
287
|
+
# cannot answer `class_name` or a subclass of it (`ConstructedClass.elsewhere?`) is left out, with its
|
|
288
|
+
# subclasses.
|
|
289
|
+
def each_subclass(scope, class_name, possible_only: false)
|
|
286
290
|
children = children_by_parent(scope)
|
|
287
291
|
record_subclasses_read(class_name)
|
|
288
|
-
|
|
292
|
+
unknown = unknown_superclass_classes(scope, class_name)
|
|
293
|
+
unknown = unknown.reject { |name| ConstructedClass.elsewhere?(scope, name, class_name) } if possible_only
|
|
294
|
+
queue = children.fetch(class_name.to_s, []) + unknown
|
|
289
295
|
seen = {}
|
|
290
296
|
until queue.empty?
|
|
291
297
|
subclass = queue.shift
|
|
@@ -300,10 +306,32 @@ module Rigor
|
|
|
300
306
|
|
|
301
307
|
def unknown_superclass_classes(scope, class_name)
|
|
302
308
|
Analysis::DependencyRecorder.read_name(:subclass_of, "*") if Analysis::DependencyRecorder.active?
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
309
|
+
table = subclass_memo(scope)
|
|
310
|
+
all = table[:unknown] ||= begin
|
|
311
|
+
mark = Scope::DiscoveryIndex::ENVELOPE_META_SUPERCLASS_MARK
|
|
312
|
+
computed = scope.discovered_superclasses.filter_map { |child, parent| child if parent.nil? }
|
|
313
|
+
meta = scope.discovered_parameter_envelopes.filter_map { |name, bucket| name if bucket.key?(mark) }
|
|
314
|
+
(computed + meta).uniq.freeze
|
|
315
|
+
end
|
|
316
|
+
name = class_name.to_s
|
|
317
|
+
all.include?(name) ? all - [name] : all
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
SUBCLASS_MEMO_KEY = :__rigor_own_method_subclasses__
|
|
321
|
+
private_constant :SUBCLASS_MEMO_KEY
|
|
322
|
+
|
|
323
|
+
# One thread-local slot keyed on the scope's discovery index (a frozen `Data`) and environment, the only
|
|
324
|
+
# inputs `children_by_parent` and the unknown-superclass list read; neither files a dependency edge
|
|
325
|
+
# (`each_subclass` files its own `subclass_of:` edges on every call), so a hit drops none.
|
|
326
|
+
def subclass_memo(scope)
|
|
327
|
+
slot = Thread.current[SUBCLASS_MEMO_KEY]
|
|
328
|
+
discovery = scope.discovery
|
|
329
|
+
environment = scope.environment
|
|
330
|
+
unless slot && slot[0].equal?(discovery) && slot[1].equal?(environment)
|
|
331
|
+
slot = [discovery, environment, {}]
|
|
332
|
+
Thread.current[SUBCLASS_MEMO_KEY] = slot
|
|
333
|
+
end
|
|
334
|
+
slot[2]
|
|
307
335
|
end
|
|
308
336
|
|
|
309
337
|
def record_subclasses_read(class_name)
|
|
@@ -313,10 +341,11 @@ module Rigor
|
|
|
313
341
|
end
|
|
314
342
|
|
|
315
343
|
def children_by_parent(scope)
|
|
316
|
-
scope
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
344
|
+
subclass_memo(scope)[:children] ||=
|
|
345
|
+
scope.discovered_superclasses.each_with_object({}) do |(child, raw_parent), children|
|
|
346
|
+
parent = scope.ancestor_name_candidates(child, raw_parent).find { |name| known_class?(scope, name) }
|
|
347
|
+
(children[parent] ||= []) << child if parent
|
|
348
|
+
end.each_value(&:freeze).freeze
|
|
320
349
|
end
|
|
321
350
|
|
|
322
351
|
def known_class?(scope, name)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../type"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Inference
|
|
9
|
+
# Issue #1889 — `instance_variable_set` called on `self` writes an instance variable that no write node shows.
|
|
10
|
+
# Left unseen, the binding the call site holds outlived it: `@n = 0; instance_variable_set(:@n, 5)` kept `@n`
|
|
11
|
+
# on `0`, and a block that wrote `@n` only this way never entered the captured-binding name set, so
|
|
12
|
+
# `[1, 2].inject(0) { |a, e| instance_variable_set(:@n, @n + 1); a + @n }` folded to a constant.
|
|
13
|
+
#
|
|
14
|
+
# A call counts when its receiver is implicit or `self` and it passes any argument ({.self_write?}). With
|
|
15
|
+
# exactly two plain positional arguments, the first a Symbol or String literal naming an instance variable
|
|
16
|
+
# (`:@n`, `"@n"`; Ruby accepts both), it is a write of that variable ({.literal_name}). Any other argument list
|
|
17
|
+
# — a computed name, a splat (`instance_variable_set(*args)`), a keyword hash, or a literal Ruby rejects with
|
|
18
|
+
# `NameError` (`:n`) — names an ivar the analysis cannot see, so every ivar the scope binds may have been
|
|
19
|
+
# written ({.apply}; for a rejected literal this over-approximates, which only widens).
|
|
20
|
+
# `obj.instance_variable_set` writes another object's ivar and does not count; neither does the reflective
|
|
21
|
+
# `send(:instance_variable_set, …)`.
|
|
22
|
+
module ReflectiveIvarWrite
|
|
23
|
+
METHOD_NAME = :instance_variable_set
|
|
24
|
+
private_constant :METHOD_NAME
|
|
25
|
+
|
|
26
|
+
IVAR_NAME = /\A@[A-Za-z_]\w*\z/
|
|
27
|
+
private_constant :IVAR_NAME
|
|
28
|
+
|
|
29
|
+
REPEATING_KEY = :__rigor_reflective_ivar_write_repeating__
|
|
30
|
+
private_constant :REPEATING_KEY
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# True when `node` is an `instance_variable_set` on `self` with at least one argument.
|
|
35
|
+
def self_write?(node)
|
|
36
|
+
return false unless node.is_a?(Prism::CallNode) && node.name == METHOD_NAME
|
|
37
|
+
return false unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
|
|
38
|
+
|
|
39
|
+
arguments = node.arguments&.arguments
|
|
40
|
+
!arguments.nil? && !arguments.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# `:@n` for `instance_variable_set(:@n, v)` or `instance_variable_set("@n", v)` on `self`, else nil.
|
|
44
|
+
def literal_name(node)
|
|
45
|
+
return nil unless self_write?(node)
|
|
46
|
+
|
|
47
|
+
arguments = node.arguments.arguments
|
|
48
|
+
return nil unless arguments.size == 2 && arguments.none? { |arg| splat_or_keywords?(arg) }
|
|
49
|
+
|
|
50
|
+
name = arguments.first
|
|
51
|
+
text =
|
|
52
|
+
case name
|
|
53
|
+
when Prism::SymbolNode, Prism::StringNode then name.unescaped
|
|
54
|
+
end
|
|
55
|
+
text && IVAR_NAME.match?(text) ? text.to_sym : nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# True for a call {.self_write?} takes whose target {.literal_name} cannot name: it may write any ivar.
|
|
59
|
+
def unknown_target?(node)
|
|
60
|
+
self_write?(node) && literal_name(node).nil?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The value argument of a call {.literal_name} names.
|
|
64
|
+
def value_node(node)
|
|
65
|
+
node.arguments.arguments[1]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Runs the block with the evaluator inside a `for` body or a `while` / `until` predicate. A `for` loop has no
|
|
69
|
+
# rebind fixpoint, and a predicate runs before every iteration but is walked once, so a literal-name write there
|
|
70
|
+
# must not replace the binding with one pass's value ({.apply}). A `while` / `until` body does not enter it: its
|
|
71
|
+
# fixpoint widens the ivar (issue #1930).
|
|
72
|
+
def in_repeating_body
|
|
73
|
+
depth = Thread.current[REPEATING_KEY] || 0
|
|
74
|
+
Thread.current[REPEATING_KEY] = depth + 1
|
|
75
|
+
begin
|
|
76
|
+
yield
|
|
77
|
+
ensure
|
|
78
|
+
Thread.current[REPEATING_KEY] = depth
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def repeating_body?
|
|
83
|
+
(Thread.current[REPEATING_KEY] || 0).positive?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# `scope` after `node` runs. A literal name binds that ivar to the value's type (yielded), as `@n = v` does.
|
|
87
|
+
# Inside a `for` body or a loop predicate ({.in_repeating_body}) the value and `Dynamic[top]` are joined into the
|
|
88
|
+
# current binding instead: nothing widens the single pass's value across iterations there. Any other call joins
|
|
89
|
+
# `Dynamic[top]` into every ivar `scope` binds: any one of them may now hold the value, and joining the
|
|
90
|
+
# value's own type would report a method the old value has and the new one lacks. An ivar on its ADR-58
|
|
91
|
+
# declaration seed keeps its mark, which stops a nil in the seed from fuelling a nil-receiver diagnostic.
|
|
92
|
+
def apply(node, scope)
|
|
93
|
+
return scope unless self_write?(node)
|
|
94
|
+
|
|
95
|
+
name = literal_name(node)
|
|
96
|
+
return bind_literal(scope, name, yield) if name
|
|
97
|
+
|
|
98
|
+
floor = Type::Combinator.untyped
|
|
99
|
+
scope.ivars.reduce(scope) do |acc, (ivar, type)|
|
|
100
|
+
widened = Type::Combinator.union(type, floor)
|
|
101
|
+
rebound = acc.with_ivar(ivar, widened).without_ivar_guard(ivar)
|
|
102
|
+
scope.declaration_sourced?(:ivar, ivar) ? rebound.seed_declaration_sourced_ivar(ivar, widened) : rebound
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def bind_literal(scope, name, value)
|
|
107
|
+
type = repeating_body? ? Type::Combinator.union(*scope.ivar(name), value, Type::Combinator.untyped) : value
|
|
108
|
+
scope.with_ivar(name, type).without_ivar_guard(name)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def splat_or_keywords?(arg)
|
|
112
|
+
arg.is_a?(Prism::SplatNode) || arg.is_a?(Prism::KeywordHashNode) || arg.is_a?(Prism::BlockArgumentNode)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|