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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../analysis/path_expansion"
|
|
4
|
+
require_relative "../rooted_glob"
|
|
4
5
|
|
|
5
6
|
module Rigor
|
|
6
7
|
class Environment
|
|
@@ -42,6 +43,36 @@ module Rigor
|
|
|
42
43
|
# anything.
|
|
43
44
|
PATTERN = /(?:(?<![\w.$@])|(?<=Kernel\.))require[ \t]*\(?[ \t]*(["'])(#{FEATURE_ALTERNATION})\1/
|
|
44
45
|
|
|
46
|
+
# Issue #2174 — the gems Ruby 4.0 bundles (its `gems/bundled_gems` list, which an installation does not
|
|
47
|
+
# keep): installed beside the interpreter, but loaded only by an explicit `require`. Gems installed later
|
|
48
|
+
# into the same directory are deliberately not read from it.
|
|
49
|
+
BUNDLED_GEMS = %w[
|
|
50
|
+
abbrev base64 benchmark bigdecimal csv debug drb fiddle getoptlong irb logger matrix minitest mutex_m
|
|
51
|
+
net-ftp net-imap net-pop net-smtp nkf observer ostruct power_assert prime pstore racc rake rbs rdoc readline
|
|
52
|
+
reline repl_type_completor resolv-replace rexml rinda rss syslog test-unit typeprof win32ole
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
# Issue #2174 — the first path segments of the features Ruby ships: the `.rb` files and directories of the
|
|
56
|
+
# interpreter's library directory, the extensions and directories of its architecture directory, and the
|
|
57
|
+
# bundled gems' names (`net-ftp` → `net`). Read once, at load time and on the main Ractor, since
|
|
58
|
+
# `RbConfig::CONFIG` is not shareable; empty when the directories cannot be read.
|
|
59
|
+
SHIPPED_SEGMENTS = begin
|
|
60
|
+
require "rbconfig"
|
|
61
|
+
segments = Set.new
|
|
62
|
+
[RbConfig::CONFIG["rubylibdir"], RbConfig::CONFIG["archdir"]].compact.each do |dir|
|
|
63
|
+
next unless File.directory?(dir)
|
|
64
|
+
|
|
65
|
+
Dir.children(dir).each { |entry| segments << entry.sub(/\.(?:rb|so|bundle|dll)\z/, "") }
|
|
66
|
+
end
|
|
67
|
+
BUNDLED_GEMS.each { |name| segments << name.split("-").first }
|
|
68
|
+
segments.to_set(&:freeze).freeze
|
|
69
|
+
rescue StandardError
|
|
70
|
+
Set.new.freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A `require` of any literal feature, read as {PATTERN} reads one; {.features_of} keeps those Ruby ships.
|
|
74
|
+
ANY_FEATURE = %r{(?:(?<![\w.$@])|(?<=Kernel\.))require[ \t]*\(?[ \t]*(["'])([\w-]+(?:/[\w-]+)*)\1}
|
|
75
|
+
|
|
45
76
|
module_function
|
|
46
77
|
|
|
47
78
|
# The features a run over `configuration` requires: every file under the configured paths, plus
|
|
@@ -53,37 +84,51 @@ module Rigor
|
|
|
53
84
|
def for_configuration(configuration, extra_files = [], sources: {})
|
|
54
85
|
files = Analysis::PathExpansion.ruby_files(configuration.paths, configuration.exclude_patterns,
|
|
55
86
|
base: configuration.exclude_base)
|
|
56
|
-
scan(files + Array(extra_files).map(&:to_s), sources: sources
|
|
87
|
+
scan(files + Array(extra_files).map(&:to_s), sources: sources,
|
|
88
|
+
sig_paths: configured_sig_paths(configuration))
|
|
57
89
|
rescue StandardError
|
|
58
90
|
[]
|
|
59
91
|
end
|
|
60
92
|
|
|
93
|
+
# The project signature roots the environment loads: the configured `signature_paths:`, else `./sig` when it
|
|
94
|
+
# exists (`Environment.project_signature_roots`, which this leaf file does not load).
|
|
95
|
+
def configured_sig_paths(configuration)
|
|
96
|
+
configured = configuration.signature_paths
|
|
97
|
+
return Array(configured).map(&:to_s) if configured
|
|
98
|
+
|
|
99
|
+
File.directory?("sig") ? ["sig"] : []
|
|
100
|
+
end
|
|
101
|
+
|
|
61
102
|
# @param files — source paths (Strings or Pathnames); an unreadable one contributes nothing.
|
|
62
103
|
# @param sources — path => source text, scanned instead of the file; its paths are scanned even when
|
|
63
104
|
# `files` does not list them.
|
|
64
|
-
# @
|
|
65
|
-
|
|
105
|
+
# @param sig_paths — the project's signature roots, whose `.rbs` declaration headers count as the project's
|
|
106
|
+
# own names ({.relevant}).
|
|
107
|
+
# @return the sorted, frozen features some file requires, a shipped one kept only when {.relevant}.
|
|
108
|
+
def scan(files, sources: {}, sig_paths: [])
|
|
66
109
|
sources = sources.to_h { |path, text| [File.expand_path(path.to_s), text] }
|
|
67
110
|
paths = (Array(files).map { |path| source_key(File.expand_path(path.to_s), sources) } + sources.keys).uniq
|
|
68
111
|
found = Set.new
|
|
112
|
+
names = Set.new
|
|
69
113
|
paths.each do |path|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
114
|
+
features, declared = sources.key?(path) ? scan_source(sources[path]) : memoized_scan_in(path)
|
|
115
|
+
found.merge(features)
|
|
116
|
+
names.merge(declared)
|
|
73
117
|
end
|
|
74
118
|
prune_memo(paths)
|
|
75
|
-
found.
|
|
119
|
+
relevant(found, names.merge(sig_names(sig_paths))).sort.freeze
|
|
76
120
|
end
|
|
77
121
|
|
|
78
|
-
# @return path (absolute) =>
|
|
79
|
-
# that
|
|
122
|
+
# @return path (absolute) => `[features, declared names]` ({.scan_source}) for every file under the
|
|
123
|
+
# configured paths that has either. A long-lived reader (the language server) keeps this, overlays its open
|
|
124
|
+
# buffers and passes the union to {.relevant}.
|
|
80
125
|
def feature_files(configuration)
|
|
81
126
|
listed = Analysis::PathExpansion.ruby_files(configuration.paths, configuration.exclude_patterns,
|
|
82
127
|
base: configuration.exclude_base)
|
|
83
128
|
files = listed.map { |path| File.expand_path(path.to_s) }
|
|
84
129
|
result = files.each_with_object({}) do |path, acc|
|
|
85
|
-
|
|
86
|
-
acc[path] =
|
|
130
|
+
scanned = memoized_scan_in(path)
|
|
131
|
+
acc[path] = scanned unless scanned.all?(&:empty?)
|
|
87
132
|
end
|
|
88
133
|
prune_memo(files)
|
|
89
134
|
result
|
|
@@ -91,6 +136,42 @@ module Rigor
|
|
|
91
136
|
{}
|
|
92
137
|
end
|
|
93
138
|
|
|
139
|
+
# Issue #2174 — `features` with each shipped feature kept only when one of `names` (constant names the
|
|
140
|
+
# project's source or signatures declare a class or module under) is a root the feature's library declares
|
|
141
|
+
# ({RubyLibraryRoots}). Only such a feature can open a class (`RbsLoader#ruby_library_reopen?` asks it of a
|
|
142
|
+
# class every declaration of which is the project's), so dropping the rest changes no diagnostic, and keeps a
|
|
143
|
+
# `require "json"` edit in a project that reopens no library class out of the incremental snapshot's
|
|
144
|
+
# fingerprint and the language server's environment key. The listed features ({VENDORED_DIRS}) are kept.
|
|
145
|
+
def relevant(features, names)
|
|
146
|
+
return features.to_a if features.all? { |feature| VENDORED_DIRS.key?(feature) }
|
|
147
|
+
|
|
148
|
+
segments = names.empty? ? EMPTY_SEGMENTS : library_segments(names)
|
|
149
|
+
features.select { |feature| VENDORED_DIRS.key?(feature) || segments.include?(feature.split("/").first) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The feature segments of the libraries that declare one of `names` as a root. Loaded lazily: the roots read
|
|
153
|
+
# the `rbs` stdlib tree, which a run with no shipped `require` never needs.
|
|
154
|
+
def library_segments(names)
|
|
155
|
+
require_relative "ruby_library_roots"
|
|
156
|
+
roots = RubyLibraryRoots.shared
|
|
157
|
+
names.each_with_object(Set.new) { |name, acc| acc.merge(roots.fetch(name, EMPTY_SEGMENTS)) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
EMPTY_SEGMENTS = [].freeze
|
|
161
|
+
|
|
162
|
+
# The constant names the `.rbs` files under `sig_paths` declare a class or module under.
|
|
163
|
+
def sig_names(sig_paths)
|
|
164
|
+
Array(sig_paths).each_with_object(Set.new) do |root, acc|
|
|
165
|
+
next unless File.directory?(root.to_s)
|
|
166
|
+
|
|
167
|
+
RootedGlob.glob(root.to_s, "**/*.rbs").each do |path|
|
|
168
|
+
acc.merge(declared_names(File.binread(path)))
|
|
169
|
+
rescue SystemCallError, IOError
|
|
170
|
+
next
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
94
175
|
# Paths are compared expanded, so a relative `--instead-of=lib/a.rb` replaces the configured
|
|
95
176
|
# `/project/lib/a.rb`; a source whose real path matches (a symlinked directory) replaces it too. The real
|
|
96
177
|
# path is asked only for a file that shares a source's basename, so a scan does not resolve every path.
|
|
@@ -114,8 +195,8 @@ module Rigor
|
|
|
114
195
|
# build; a long-lived language server on every rebuild), so on the main Ractor each file's answer is kept
|
|
115
196
|
# against its stat and the file is re-read only when the stat moved. A non-main Ractor may not touch the
|
|
116
197
|
# module's state and reads the file every time.
|
|
117
|
-
def
|
|
118
|
-
return
|
|
198
|
+
def memoized_scan_in(path)
|
|
199
|
+
return scan_in(path) unless Ractor.current == Ractor.main
|
|
119
200
|
|
|
120
201
|
stat = File.stat(path)
|
|
121
202
|
stamp = [stat.mtime.to_r, stat.ctime.to_r, stat.size, stat.ino]
|
|
@@ -123,13 +204,15 @@ module Rigor
|
|
|
123
204
|
cached = memo[path]
|
|
124
205
|
return cached.last if cached && cached.first == stamp
|
|
125
206
|
|
|
126
|
-
|
|
127
|
-
memo[path] = [stamp,
|
|
128
|
-
|
|
207
|
+
scanned = scan_in(path)
|
|
208
|
+
memo[path] = [stamp, scanned]
|
|
209
|
+
scanned
|
|
129
210
|
rescue SystemCallError
|
|
130
|
-
|
|
211
|
+
NOTHING
|
|
131
212
|
end
|
|
132
213
|
|
|
214
|
+
NOTHING = [[].freeze, [].freeze].freeze
|
|
215
|
+
|
|
133
216
|
# Keeps the memo to the files the latest scan was asked about, so a long-lived language server holds one
|
|
134
217
|
# entry per project file rather than one per file it ever saw.
|
|
135
218
|
def prune_memo(paths)
|
|
@@ -139,28 +222,51 @@ module Rigor
|
|
|
139
222
|
@memo.select! { |path, _| keep.include?(path) }
|
|
140
223
|
end
|
|
141
224
|
|
|
142
|
-
# @return
|
|
143
|
-
def
|
|
144
|
-
|
|
225
|
+
# @return `[features, declared names]` for one file ({.scan_source}).
|
|
226
|
+
def scan_in(path)
|
|
227
|
+
scan_source(File.binread(path))
|
|
145
228
|
rescue SystemCallError, IOError
|
|
146
|
-
|
|
229
|
+
NOTHING
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# @return `[features, declared names]`: what {.features_of} reads, and the constant names a `class` or
|
|
233
|
+
# `module` header in `source` spells, each segment on its own (`class Net::SMTP` gives `Net` and `SMTP`).
|
|
234
|
+
def scan_source(source)
|
|
235
|
+
[features_of(source), declared_names(source)].freeze
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# A header on a line of its own, or inside a comment (an rbs-inline `# @rbs!` block declares
|
|
239
|
+
# `# class Matrix`). Over-matching a comment only keeps more features.
|
|
240
|
+
HEADER = /^[ \t]*(?:#[ \t]*)?(?:class|module)[ \t]+(?:::)?([A-Z]\w*(?:::[A-Z]\w*)*)/
|
|
241
|
+
|
|
242
|
+
def declared_names(source)
|
|
243
|
+
bytes = source.b
|
|
244
|
+
return [] unless bytes.include?("class") || bytes.include?("module")
|
|
245
|
+
|
|
246
|
+
bytes.scan(HEADER).flat_map { |(name)| name.split("::") }
|
|
247
|
+
.uniq.map { |name| name.dup.force_encoding(Encoding::UTF_8).freeze }.freeze
|
|
147
248
|
end
|
|
148
249
|
|
|
149
|
-
# @return the features `source` requires
|
|
250
|
+
# @return the features `source` requires: the listed ones ({VENDORED_DIRS}), and, for issue #2174, every
|
|
251
|
+
# feature Ruby ships ({SHIPPED_SEGMENTS}), which `RbsLoader#ruby_library_reopen?` reads to tell a reopened
|
|
252
|
+
# library class from a project class of the same name. Only the listed ones become library tokens.
|
|
150
253
|
def features_of(source)
|
|
151
|
-
# A plain substring test first:
|
|
152
|
-
# the regexp on a file that
|
|
153
|
-
return [] unless
|
|
254
|
+
# A plain substring test first: a file with no `require` at all is the common case, and it is far cheaper
|
|
255
|
+
# than the regexp on a file that has none.
|
|
256
|
+
return [] unless source.include?("require")
|
|
154
257
|
|
|
155
258
|
bytes = source.b
|
|
156
259
|
named = []
|
|
157
260
|
position = 0
|
|
158
|
-
while (match =
|
|
261
|
+
while (match = ANY_FEATURE.match(bytes, position))
|
|
159
262
|
named << match[2] unless commented?(bytes, match.begin(0))
|
|
160
263
|
position = match.end(0)
|
|
161
264
|
end
|
|
162
|
-
# The table's own frozen keys, so the result is shareable across Ractors
|
|
163
|
-
|
|
265
|
+
# The table's own frozen keys, and frozen copies of the rest, so the result is shareable across Ractors
|
|
266
|
+
# whatever the source's encoding.
|
|
267
|
+
listed = VENDORED_DIRS.each_key.select { |feature| named.include?(feature) }
|
|
268
|
+
shipped = named.select { |feature| SHIPPED_SEGMENTS.include?(feature.split("/").first) }
|
|
269
|
+
(listed + shipped.map { |feature| feature.dup.force_encoding(Encoding::UTF_8).freeze }).uniq
|
|
164
270
|
end
|
|
165
271
|
|
|
166
272
|
# Whether the match at `offset` sits on a line whose first non-blank character is `#`: a whole-line comment.
|
|
@@ -176,7 +282,8 @@ module Rigor
|
|
|
176
282
|
# The library tokens for `features` and for any listed feature the configuration's `libraries:` names.
|
|
177
283
|
def tokens(features, configured_libraries)
|
|
178
284
|
configured = Array(configured_libraries).map(&:to_s).select { |name| VENDORED_DIRS.key?(name) }
|
|
179
|
-
|
|
285
|
+
listed = Array(features).select { |feature| VENDORED_DIRS.key?(feature) }
|
|
286
|
+
(listed | configured).sort.map { |feature| token(feature) }
|
|
180
287
|
end
|
|
181
288
|
|
|
182
289
|
def token(feature)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbs"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require_relative "missing_gem_constant_index"
|
|
6
|
+
require_relative "required_features"
|
|
7
|
+
require_relative "../rooted_glob"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
class Environment
|
|
11
|
+
# Issue #2174 — which library Ruby ships declares each root constant name. Three sources are read, and no others:
|
|
12
|
+
#
|
|
13
|
+
# - every library of the `rbs` gem's `stdlib/` tree, loaded into this run or not, read from its `.rbs` files
|
|
14
|
+
# (`net-http` declares `Net`);
|
|
15
|
+
# - every top-level `.rb` file of the interpreter's library directory (`RbConfig::CONFIG["rubylibdir"]`), the
|
|
16
|
+
# default gems' and the standard library's entry files, read from their top-level declarations;
|
|
17
|
+
# - the entry file of each gem Ruby bundles ({RequiredFeatures::BUNDLED_GEMS}), found under the interpreter's
|
|
18
|
+
# gem directory. A gem installed there later is not one of them and is not read.
|
|
19
|
+
#
|
|
20
|
+
# Each root maps to the first path segments of the features that load it (`Matrix` → `matrix`, `Net` → `net`),
|
|
21
|
+
# which {RbsLoader#ruby_library_reopen?} intersects with the features the project's source requires
|
|
22
|
+
# ({RequiredFeatures.features_of}).
|
|
23
|
+
#
|
|
24
|
+
# Ownership is read, never guessed by camelizing a library name, for the reason {MissingGemConstantIndex}
|
|
25
|
+
# gives; that module's reader parses the `.rb` entry files. No library code runs. Everything fails open to
|
|
26
|
+
# "no library", which keeps the pre-#2174 reading. Core names (`Array`, `Kernel`) appear too, harmlessly:
|
|
27
|
+
# core signatures always load, so no project-only class carries one.
|
|
28
|
+
#
|
|
29
|
+
# The directories are resolved at load time, on the main Ractor: `RbConfig::CONFIG` and `Gem.default_dir`
|
|
30
|
+
# read non-shareable constants and raise `Ractor::IsolationError` inside a pool worker. The scan itself runs
|
|
31
|
+
# lazily (about 0.1 s), memoised per loader, and only for a class about to be reported.
|
|
32
|
+
module RubyLibraryRoots
|
|
33
|
+
STDLIB_ROOT = begin
|
|
34
|
+
::RBS::Repository::DEFAULT_STDLIB_ROOT.to_s.dup.freeze
|
|
35
|
+
rescue StandardError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
RUBY_LIB_DIR = begin
|
|
40
|
+
RbConfig::CONFIG["rubylibdir"].to_s.dup.freeze
|
|
41
|
+
rescue StandardError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
RUBY_GEMS_DIR = begin
|
|
46
|
+
File.join(Gem.default_dir, "gems").freeze
|
|
47
|
+
rescue StandardError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
# {.build}, built once per process on the main Ractor, where the language server and the incremental
|
|
54
|
+
# snapshot fingerprint ask it repeatedly; a non-main Ractor may not keep module state and builds its own.
|
|
55
|
+
def shared
|
|
56
|
+
return build unless Ractor.current == Ractor.main
|
|
57
|
+
|
|
58
|
+
@shared ||= build
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The frozen map of root constant name to the frozen set of feature segments that load it.
|
|
62
|
+
def build
|
|
63
|
+
roots = Hash.new { |hash, key| hash[key] = Set.new }
|
|
64
|
+
stdlib_roots(roots)
|
|
65
|
+
ruby_lib_roots(roots)
|
|
66
|
+
bundled_gem_roots(roots)
|
|
67
|
+
roots.each_value(&:freeze)
|
|
68
|
+
roots.default_proc = nil
|
|
69
|
+
roots.freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def stdlib_roots(roots)
|
|
73
|
+
return if STDLIB_ROOT.nil? || !File.directory?(STDLIB_ROOT)
|
|
74
|
+
|
|
75
|
+
Dir.children(STDLIB_ROOT).each do |library|
|
|
76
|
+
segment = library.split("-").first
|
|
77
|
+
RootedGlob.glob(File.join(STDLIB_ROOT, library), "*/**/*.rbs").each do |path|
|
|
78
|
+
stdlib_file_roots(path).each { |root| roots[root] << segment }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def stdlib_file_roots(path)
|
|
84
|
+
_, _, decls = ::RBS::Parser.parse_signature(File.read(path))
|
|
85
|
+
decls.filter_map { |decl| root_of(decl) if named_declaration?(decl) }
|
|
86
|
+
rescue StandardError
|
|
87
|
+
[]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def ruby_lib_roots(roots)
|
|
91
|
+
return if RUBY_LIB_DIR.nil? || !File.directory?(RUBY_LIB_DIR)
|
|
92
|
+
|
|
93
|
+
RootedGlob.glob(RUBY_LIB_DIR, "*.rb").each do |path|
|
|
94
|
+
segment = File.basename(path, ".rb")
|
|
95
|
+
MissingGemConstantIndex.top_level_root_constants(path).each { |root| roots[root] << segment }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def bundled_gem_roots(roots)
|
|
100
|
+
return if RUBY_GEMS_DIR.nil? || !File.directory?(RUBY_GEMS_DIR)
|
|
101
|
+
|
|
102
|
+
Dir.children(RUBY_GEMS_DIR).each do |basename|
|
|
103
|
+
gem_name = basename[/\A(.+?)-\d/, 1]
|
|
104
|
+
next if gem_name.nil? || !RequiredFeatures::BUNDLED_GEMS.include?(gem_name)
|
|
105
|
+
|
|
106
|
+
segment = gem_name.split("-").first
|
|
107
|
+
MissingGemConstantIndex.entry_files(File.join(RUBY_GEMS_DIR, basename), gem_name).each do |path|
|
|
108
|
+
MissingGemConstantIndex.top_level_root_constants(path).each { |root| roots[root] << segment }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def named_declaration?(decl)
|
|
114
|
+
decl.is_a?(::RBS::AST::Declarations::Class) || decl.is_a?(::RBS::AST::Declarations::Module)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def root_of(decl)
|
|
118
|
+
decl.name.to_s.delete_prefix("::").split("::").first
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
data/lib/rigor/environment.rb
CHANGED
|
@@ -268,7 +268,7 @@ module Rigor
|
|
|
268
268
|
# {Environment::RbsLoader} so constant lookups (and, in later v0.0.9 slices, other reflection
|
|
269
269
|
# artefacts) consult the cache. Pass `nil` (the default) to skip caching for this environment.
|
|
270
270
|
# rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists
|
|
271
|
-
def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil,
|
|
271
|
+
def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil, # rubocop:disable Metrics/AbcSize
|
|
272
272
|
plugin_registry: nil, dependency_source_index: nil,
|
|
273
273
|
rbs_extended_reporter: nil, boundary_cross_reporter: nil,
|
|
274
274
|
source_rbs_synthesis_reporter: nil,
|
|
@@ -364,7 +364,9 @@ module Rigor
|
|
|
364
364
|
# Issue #610 — the plugin-contributed subset is loaded LAST and may stand down against a colliding
|
|
365
365
|
# generic arity. Only the bundled plugins' own `sig/` is deferred: the user's `signature_paths:`,
|
|
366
366
|
# the bundle walk and `rbs collection install` all stay authoritative.
|
|
367
|
-
deferred_signature_paths: plugin_sig_paths
|
|
367
|
+
deferred_signature_paths: plugin_sig_paths,
|
|
368
|
+
project_signature_paths: resolved_paths,
|
|
369
|
+
required_features: required_features || RequiredFeatures.scan(source_files, sig_paths: resolved_paths)
|
|
368
370
|
)
|
|
369
371
|
# Issues #824 / #1075 — the rows ADR-112 WD5's consistency rule reports for members the project's
|
|
370
372
|
# own `sig/` and an inline source both declare: an error per contradiction (or per refinement that
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Inference
|
|
5
|
+
# Issue #2104 — the callee floor's census made transitive. A method that hands its parameter to another method
|
|
6
|
+
# which mutates it (`def stripes(acc) = poke(acc)`, `def poke(acc) = (acc[0] = "x")`) mutates that parameter as
|
|
7
|
+
# surely as one that writes it itself, so the caller's argument is floored after `stripes(acc)` too.
|
|
8
|
+
#
|
|
9
|
+
# This module is the closure over a parameter-flow graph, independent of how the graph is read. `own` answers a
|
|
10
|
+
# def's own census, `{ name => slot }` (a slot is a positional index or a keyword symbol); `edges` answers the
|
|
11
|
+
# {Edge}s along which a def hands one of its parameters to a resolved callee. A node is one parameter of one def,
|
|
12
|
+
# and the walk follows only the edges that hand on the parameter the node is, so a def's hand-offs of its OTHER
|
|
13
|
+
# parameters cost nothing. A node is mutated when its def mutates it or it hands itself to a mutated node: the
|
|
14
|
+
# least fixpoint, so a recursive or mutually recursive chain terminates and a cycle with no mutation on it mutates
|
|
15
|
+
# nothing.
|
|
16
|
+
#
|
|
17
|
+
# The walk reads at most {MAX_NODES} nodes per closure. A hand-off to a node past the cap is read as no hand-off,
|
|
18
|
+
# as the one-level floor reads a callee it cannot resolve: flooring there would drop a literal shape on evidence
|
|
19
|
+
# the walk never saw, and a floored `Array[Dynamic[top]]` then fails a declared tuple on correct code.
|
|
20
|
+
module CalleeMutationChain
|
|
21
|
+
# `name` and `slot` are the forwarding def's parameter, `callee` the def it is handed to, and `callee_names`
|
|
22
|
+
# the callee's parameters it may bind there.
|
|
23
|
+
Edge = Data.define(:name, :slot, :callee, :callee_names)
|
|
24
|
+
|
|
25
|
+
MAX_NODES = 32
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def close(root, own:, edges:)
|
|
30
|
+
census = own.call(root)
|
|
31
|
+
handed = edges.call(root).reject { |edge| census.key?(edge.name) }
|
|
32
|
+
return census if handed.empty?
|
|
33
|
+
|
|
34
|
+
flow = Flow.new(own, edges)
|
|
35
|
+
handed.each { |edge| flow.visit(root, edge.name) }
|
|
36
|
+
flow.settle
|
|
37
|
+
handed.each_with_object(census.dup) do |edge, result|
|
|
38
|
+
result[edge.name] = edge.slot if flow.mutated?(root, edge.name)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The parameter-flow graph one closure reads: per def, per parameter name, whether the node is mutated and the
|
|
43
|
+
# nodes it hands itself on to.
|
|
44
|
+
class Flow
|
|
45
|
+
Node = Struct.new(:mutated, :successors)
|
|
46
|
+
|
|
47
|
+
def initialize(own, edges)
|
|
48
|
+
@own = own
|
|
49
|
+
@edges = edges
|
|
50
|
+
@nodes = {}.compare_by_identity
|
|
51
|
+
@count = 0
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mutated?(def_node, name)
|
|
55
|
+
node = @nodes.dig(def_node, name)
|
|
56
|
+
!node.nil? && node.mutated
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Reads the nodes reachable from `def_node`'s `name`, breadth first, while fewer than {MAX_NODES} are read.
|
|
60
|
+
def visit(def_node, name)
|
|
61
|
+
queue = [[def_node, name]]
|
|
62
|
+
until queue.empty?
|
|
63
|
+
current, parameter = queue.shift
|
|
64
|
+
next if known?(current, parameter)
|
|
65
|
+
break if @count >= MAX_NODES
|
|
66
|
+
|
|
67
|
+
queue.concat(add(current, parameter))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def settle
|
|
72
|
+
changed = true
|
|
73
|
+
while changed
|
|
74
|
+
changed = false
|
|
75
|
+
each_node do |node|
|
|
76
|
+
next if node.mutated
|
|
77
|
+
next unless node.successors.any? { |callee, name| mutated?(callee, name) }
|
|
78
|
+
|
|
79
|
+
node.mutated = true
|
|
80
|
+
changed = true
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def known?(def_node, name)
|
|
88
|
+
by_name = @nodes[def_node]
|
|
89
|
+
!by_name.nil? && by_name.key?(name)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Records the node and answers its successors. A node its def mutates needs none.
|
|
93
|
+
def add(def_node, name)
|
|
94
|
+
@count += 1
|
|
95
|
+
mutated = @own.call(def_node).key?(name)
|
|
96
|
+
successors = mutated ? [] : successors_of(def_node, name)
|
|
97
|
+
(@nodes[def_node] ||= {})[name] = Node.new(mutated, successors)
|
|
98
|
+
successors
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def successors_of(def_node, name)
|
|
102
|
+
@edges.call(def_node).each_with_object([]) do |edge, successors|
|
|
103
|
+
next unless edge.name == name
|
|
104
|
+
|
|
105
|
+
edge.callee_names.each { |callee_name| successors << [edge.callee, callee_name] }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def each_node(&)
|
|
110
|
+
@nodes.each_value { |by_name| by_name.each_value(&) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "../type"
|
|
5
|
+
require_relative "../source/node_walker"
|
|
6
|
+
require_relative "mutation_rejoin"
|
|
7
|
+
require_relative "receiver_alias"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module Inference
|
|
11
|
+
# A loop hands its collection's own elements to its body (issue #2262), so a body that mutates one in place
|
|
12
|
+
# changes a shape that the collection's binding still carries one level down. This module is the two halves of
|
|
13
|
+
# dropping that shape: whether the body mutates an element, and the type with every shape dropped at every depth.
|
|
14
|
+
module DeepErasure
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# {MutationWidening.widen_tuple} / {MutationWidening.widen_hash_shape} applied at every depth: each `Tuple` /
|
|
18
|
+
# `HashShape` the type holds, as a union member or a type argument, drops to its nominal, so `[[1]]` reads
|
|
19
|
+
# `Array[Array[Integer]]`. A loop's element is the collection's own object, so a body that mutates it in place
|
|
20
|
+
# (`x << 2`) changes a shape one level down that the collection's binding still carries.
|
|
21
|
+
def widen(type)
|
|
22
|
+
case type
|
|
23
|
+
when Type::Tuple then widen_args(MutationWidening.widen_tuple(type))
|
|
24
|
+
when Type::HashShape then widen_args(MutationWidening.widen_hash_shape(type))
|
|
25
|
+
when Type::Nominal then widen_args(type)
|
|
26
|
+
when Type::Difference then widen(type.base)
|
|
27
|
+
when Type::Union then Type::Combinator.union(*type.members.map { |member| widen(member) })
|
|
28
|
+
else Type::Combinator.widen_value_pinned(type)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def widen_args(nominal)
|
|
33
|
+
return nominal unless nominal.is_a?(Type::Nominal) && !nominal.type_args.empty?
|
|
34
|
+
|
|
35
|
+
Type::Combinator.nominal_of(nominal.class_name, type_args: nominal.type_args.map { |arg| widen(arg) })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The parameter nodes whose names a block (or a `for` index) binds to the collection's elements.
|
|
39
|
+
ELEMENT_PARAMETER_NODES = [
|
|
40
|
+
Prism::RequiredParameterNode, Prism::OptionalParameterNode, Prism::RestParameterNode,
|
|
41
|
+
Prism::KeywordRestParameterNode, Prism::LocalVariableTargetNode
|
|
42
|
+
].freeze
|
|
43
|
+
ELEMENT_WRITE_NODES = [
|
|
44
|
+
Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode,
|
|
45
|
+
Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode,
|
|
46
|
+
Prism::IndexTargetNode, Prism::CallTargetNode
|
|
47
|
+
].freeze
|
|
48
|
+
private_constant :ELEMENT_PARAMETER_NODES, :ELEMENT_WRITE_NODES
|
|
49
|
+
|
|
50
|
+
# True when `body` mutates in place a value that a name in `names` (a loop's element) holds: a mutator
|
|
51
|
+
# (`MutationWidening::SHAPE_MUTATORS`) called on a read or an index chain rooted at it (`x << 1`,
|
|
52
|
+
# `x[0].push(1)`, `x[0] = 1`), or an operator write on one (`x[0] += 1`). Every name a block or a
|
|
53
|
+
# `for` inside the body binds counts as an element too (`x.each { |y| y << 1 }`, `for c in x; c << 1; end`),
|
|
54
|
+
# and so does `it` / `_1` wherever it is read, and an accumulator parameter (`inject([]) { |acc, e| acc << e }`):
|
|
55
|
+
# the rule is wider than "an element", which only loses precision. It does not follow an alias (`y = x`) or
|
|
56
|
+
# an element passed to a call (`add(x)`), which the straight-line widening does not either.
|
|
57
|
+
def element_mutation?(body, names)
|
|
58
|
+
return false if body.nil?
|
|
59
|
+
|
|
60
|
+
names |= nested_parameter_names(body)
|
|
61
|
+
Source::NodeWalker.each(body).any? { |node| element_mutation_site?(node, names) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The names `parameters` (a block's parameter list, or a `for` index) binds.
|
|
65
|
+
def parameter_names(parameters)
|
|
66
|
+
return [] if parameters.nil?
|
|
67
|
+
|
|
68
|
+
Source::NodeWalker.each(parameters).filter_map do |node|
|
|
69
|
+
node.name if ELEMENT_PARAMETER_NODES.include?(node.class)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def nested_parameter_names(body)
|
|
74
|
+
Source::NodeWalker.each(body).flat_map do |node|
|
|
75
|
+
case node
|
|
76
|
+
when Prism::BlockNode then parameter_names(node.parameters)
|
|
77
|
+
when Prism::ForNode then parameter_names(node.index)
|
|
78
|
+
else []
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def element_mutation_site?(node, names)
|
|
84
|
+
if node.is_a?(Prism::CallNode)
|
|
85
|
+
MutationWidening::SHAPE_MUTATORS.include?(node.name) && element_rooted?(node.receiver, names)
|
|
86
|
+
else
|
|
87
|
+
ELEMENT_WRITE_NODES.include?(node.class) && element_rooted?(node.receiver, names)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def element_rooted?(node, names)
|
|
92
|
+
node = node.receiver while node.is_a?(Prism::CallNode) && node.receiver
|
|
93
|
+
node = node.body.body.last while node.is_a?(Prism::ParenthesesNode) && node.body.is_a?(Prism::StatementsNode)
|
|
94
|
+
case node
|
|
95
|
+
when Prism::LocalVariableReadNode then names.include?(node.name) || /\A_[1-9]\z/.match?(node.name)
|
|
96
|
+
when Prism::ItLocalVariableReadNode then true
|
|
97
|
+
else false
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A block call hands each of its receiver's elements to the block's parameters, and a body that mutates one in
|
|
102
|
+
# place (`a.each { |x| x << 2 }`) changes the shape the receiver's binding still carries one level down
|
|
103
|
+
# (`[[1]]`). The receiver's variables drop their shapes at every depth ({widen}), as a `for` loop's
|
|
104
|
+
# value does ({StatementEvaluator#for_collection_after}).
|
|
105
|
+
def widen_element_aliases(receiver, body, names, scope)
|
|
106
|
+
return scope unless element_mutation?(body, names)
|
|
107
|
+
|
|
108
|
+
widen_receiver(receiver, scope)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def widen_receiver(receiver, scope)
|
|
112
|
+
ReceiverAlias.candidates(receiver).reduce(scope) { |acc, read| widen_read(read, acc) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# `a.each(&:strip!)`: a block argument naming a mutator mutates every element it is handed.
|
|
116
|
+
def symbol_proc_mutation?(block)
|
|
117
|
+
block.is_a?(Prism::BlockArgumentNode) && block.expression.is_a?(Prism::SymbolNode) &&
|
|
118
|
+
MutationWidening::SHAPE_MUTATORS.include?(block.expression.unescaped.to_sym)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def widen_symbol_proc(call_node, scope) = widen_receiver(call_node.receiver, scope)
|
|
122
|
+
|
|
123
|
+
def widen_read(read, scope)
|
|
124
|
+
getter, builder = MutationWidening::ALIAS_ACCESSORS[read.class]
|
|
125
|
+
return scope if getter.nil?
|
|
126
|
+
|
|
127
|
+
name = ReceiverAlias.read_name(read)
|
|
128
|
+
current = scope.public_send(getter, name)
|
|
129
|
+
return scope if current.nil?
|
|
130
|
+
|
|
131
|
+
widened = widen(current)
|
|
132
|
+
return scope if widened == current
|
|
133
|
+
|
|
134
|
+
MutationRejoin.rebind(scope, builder, name, widened, pre_state: current, kind: getter)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|