rigortype 0.4.2 → 0.4.3
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 +12 -5
- data/docs/manual/02-cli-reference.md +19 -1
- data/docs/manual/03-configuration.md +6 -4
- data/docs/manual/05-inspecting-types.md +50 -0
- data/docs/manual/12-caching.md +18 -1
- data/docs/manual/14-rails-quickstart.md +2 -2
- data/docs/manual/18-removing-dead-code.md +16 -9
- data/lib/rigor/analysis/check_rules/rule_ids.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +23 -22
- data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
- data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
- data/lib/rigor/analysis/incremental_session.rb +89 -22
- data/lib/rigor/analysis/path_expansion.rb +62 -6
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
- data/lib/rigor/analysis/reachability/graph.rb +119 -15
- data/lib/rigor/analysis/reachability/scan.rb +332 -173
- data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
- data/lib/rigor/analysis/run_cache_probe.rb +1 -1
- data/lib/rigor/analysis/run_stats.rb +28 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +26 -1
- data/lib/rigor/analysis/runner/pool_coordinator.rb +28 -9
- data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
- data/lib/rigor/analysis/runner.rb +29 -11
- data/lib/rigor/analysis/worker_session.rb +1 -1
- data/lib/rigor/cache/engine_source.rb +21 -7
- data/lib/rigor/cache/incremental_snapshot.rb +36 -8
- data/lib/rigor/cli/check_command.rb +138 -39
- data/lib/rigor/cli/docs_command.rb +5 -4
- data/lib/rigor/cli/lens_command.rb +144 -0
- data/lib/rigor/cli/lsp_command.rb +5 -2
- data/lib/rigor/cli/skill_command.rb +3 -3
- data/lib/rigor/cli/skill_describe.rb +1 -1
- data/lib/rigor/cli/type_of_command.rb +2 -1
- data/lib/rigor/cli/unused_command.rb +157 -24
- data/lib/rigor/cli.rb +30 -4
- data/lib/rigor/configuration.rb +15 -0
- data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
- data/lib/rigor/environment/installed_gem_set.rb +38 -11
- data/lib/rigor/environment/lockfile_resolver.rb +37 -6
- data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
- data/lib/rigor/environment/rbs_loader.rb +33 -0
- data/lib/rigor/environment/required_features.rb +4 -2
- data/lib/rigor/environment.rb +12 -5
- data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
- data/lib/rigor/inference/captured_locals.rb +43 -3
- data/lib/rigor/inference/expression_typer.rb +58 -25
- data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
- data/lib/rigor/inference/match_rebinding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
- data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
- data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +52 -3
- data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
- data/lib/rigor/inference/project_patched_methods.rb +2 -2
- data/lib/rigor/inference/project_patched_scanner.rb +38 -24
- data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
- data/lib/rigor/inference/refined_dispatch.rb +62 -14
- data/lib/rigor/inference/repeated_or_writes.rb +2 -0
- data/lib/rigor/inference/scope_indexer.rb +140 -5
- data/lib/rigor/inference/statement_evaluator.rb +223 -31
- data/lib/rigor/inference/untyped_rejoin.rb +75 -0
- data/lib/rigor/language_server/project_context.rb +56 -5
- data/lib/rigor/lens/anchor.rb +115 -0
- data/lib/rigor/lens/contract_reader.rb +118 -0
- data/lib/rigor/lens/document.rb +113 -0
- data/lib/rigor/lens/file_reader.rb +433 -0
- data/lib/rigor/lens/members.rb +153 -0
- data/lib/rigor/lens/parameters.rb +91 -0
- data/lib/rigor/lens/plugins.rb +40 -0
- data/lib/rigor/lens/run.rb +280 -0
- data/lib/rigor/lens/slot.rb +79 -0
- data/lib/rigor/lens.rb +40 -0
- data/lib/rigor/project_environment.rb +20 -1
- data/lib/rigor/protection/mutation_cache.rb +2 -1
- data/lib/rigor/scope.rb +22 -11
- data/lib/rigor/sig_gen/generator.rb +45 -5
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
- data/sig/rigor/scope.rbs +3 -0
- data/sig/rigor.rbs +6 -3
- data/skills/rigor-project-init/references/02-configure.md +2 -2
- metadata +18 -1
|
@@ -49,8 +49,8 @@ module Rigor
|
|
|
49
49
|
|
|
50
50
|
configuration = Configuration.load(options.fetch(:config))
|
|
51
51
|
paths = @argv.empty? ? configuration.paths : @argv
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
establish_root(configuration)
|
|
53
|
+
scan_cache = open_scan_cache(configuration)
|
|
54
54
|
declarations, references, dynamic_uses, shadows = scan(paths, configuration, scan_cache)
|
|
55
55
|
references.concat(signature_references(configuration))
|
|
56
56
|
dynamic_uses.concat(template_mentions(declarations, scan_cache))
|
|
@@ -68,6 +68,47 @@ module Rigor
|
|
|
68
68
|
|
|
69
69
|
private
|
|
70
70
|
|
|
71
|
+
# A scan's role and the spelling it records depend on the root and the working directory (#1760): a role
|
|
72
|
+
# reads every spelling below either ({#role_of}), and a relative path argument is recorded as written
|
|
73
|
+
# relative to the working directory.
|
|
74
|
+
def open_scan_cache(configuration)
|
|
75
|
+
cwd = canonical(Dir.pwd)
|
|
76
|
+
spelled = cwd == @root ? "" : ":#{cwd}"
|
|
77
|
+
Analysis::Reachability::ScanCache.open(configuration.cache_path, target_ruby: configuration.target_ruby,
|
|
78
|
+
root: "#{@root}#{spelled}")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The root, and its spellings: the realpath and the logical directory it was named by.
|
|
82
|
+
def establish_root(configuration)
|
|
83
|
+
@root = project_root(configuration)
|
|
84
|
+
logical = @root == canonical(Dir.pwd) ? Dir.pwd : configuration.exclude_base
|
|
85
|
+
@root_spellings = [@root, File.absolute_path(logical)].uniq
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The project root (#1760), realpath'd: the config file's directory, unless the config sits below the
|
|
89
|
+
# working directory (`--config=ci/rigor.yml` from the project root), where the working directory is the
|
|
90
|
+
# project. Run from a subdirectory, or from a directory unrelated to the config, it is the config's.
|
|
91
|
+
# The root never rises above the config's directory or the working directory, so the role rules, which
|
|
92
|
+
# anchor `qa/`, `e2e/` and `features/` at the root, keep reading the project; an analysed path outside
|
|
93
|
+
# it reads as `../…`.
|
|
94
|
+
def project_root(configuration)
|
|
95
|
+
base = canonical(configuration.exclude_base)
|
|
96
|
+
cwd = canonical(Dir.pwd)
|
|
97
|
+
inside?(base, cwd) && base != cwd ? cwd : base
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def inside?(path, dir)
|
|
101
|
+
path.start_with?("#{dir.chomp('/')}/")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The directories the wider corpus is read from: the root, and the working directory when it lies
|
|
105
|
+
# outside the root. Reading the working directory too keeps the corpus no narrower than it was when
|
|
106
|
+
# the working directory alone was read; a reference only ever makes a declaration more reachable.
|
|
107
|
+
def corpus_dirs
|
|
108
|
+
cwd = canonical(Dir.pwd)
|
|
109
|
+
cwd == @root || inside?(cwd, @root) ? [@root] : [@root, cwd]
|
|
110
|
+
end
|
|
111
|
+
|
|
71
112
|
def build_graph(configuration, options, contribution, declarations:, references:, dynamic_uses:, shadows:)
|
|
72
113
|
Analysis::Reachability::Graph.new(
|
|
73
114
|
declarations: declarations, references: references, dynamic_uses: dynamic_uses, shadows: shadows,
|
|
@@ -111,21 +152,52 @@ module Rigor
|
|
|
111
152
|
# references resolve to first (#1732).
|
|
112
153
|
# Every file is consulted every run — an unchanged one contributes its cached scan, so the
|
|
113
154
|
# whole-project completeness the `--incremental` refusal protects is unaffected.
|
|
155
|
+
#
|
|
156
|
+
# A file is parsed once however it is spelled (#1760), but every spelling of it keeps what it referred
|
|
157
|
+
# to under its own path and role, exactly as when each spelling was scanned separately: dropping a
|
|
158
|
+
# spelling lost its role, and with it a production reference a `spec/` symlink to the same file would
|
|
159
|
+
# otherwise have hidden. Only the declarations are taken once, from the first analysed spelling, which
|
|
160
|
+
# is the one the report prints; any other spelling's declarations are shadows, as a wider-corpus
|
|
161
|
+
# file's are (#1732).
|
|
114
162
|
def scan(paths, configuration, cache)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
dynamic_uses = []
|
|
120
|
-
(declaration_files + reference_files(paths, configuration)).sort.each do |file|
|
|
121
|
-
result = cache.serve(:scan, file) { read_and_scan(file, configuration) }
|
|
163
|
+
out = { declarations: [], references: [], dynamic_uses: [], shadows: [] }
|
|
164
|
+
file_spellings(paths, configuration).sort.each do |real, list|
|
|
165
|
+
primary, analysed = list.find { |_, declared| declared } || list.first
|
|
166
|
+
result = cache.serve(:scan, primary) { read_and_scan(primary, real, configuration) }
|
|
122
167
|
next if result.nil?
|
|
123
168
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
dynamic_uses.concat(result.dynamic_uses)
|
|
169
|
+
out[analysed ? :declarations : :shadows].concat(result.declarations)
|
|
170
|
+
list.map(&:first).each { |file| add_spelling(out, result, file, real, primary) }
|
|
127
171
|
end
|
|
128
|
-
|
|
172
|
+
out.values_at(:declarations, :references, :dynamic_uses, :shadows)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def add_spelling(out, result, file, real, primary)
|
|
176
|
+
out[:shadows].concat(result.declarations.map { |d| d.with(path: file) }) unless file == primary
|
|
177
|
+
role = role_of(file, real)
|
|
178
|
+
out[:references].concat(result.references.map { |r| r.with(path: file, role: role) })
|
|
179
|
+
out[:dynamic_uses].concat(result.dynamic_uses.map { |u| respelled(u, file, role) })
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# `{realpath => [[spelling, analysed?], ...]}`: the analysed spellings first, in `paths:` order, then
|
|
183
|
+
# the wider corpus's.
|
|
184
|
+
def file_spellings(paths, configuration)
|
|
185
|
+
analysed = Analysis::PathExpansion.ruby_files(paths, configuration.exclude_patterns,
|
|
186
|
+
base: configuration.exclude_base)
|
|
187
|
+
spellings = Hash.new { |hash, real| hash[real] = [] }
|
|
188
|
+
analysed.each { |file| spellings[canonical(file)] << [file, true] }
|
|
189
|
+
reference_files(paths, configuration).each { |file| spellings[canonical(file)] << [file, false] }
|
|
190
|
+
spellings = spellings.transform_values { |list| list.uniq(&:first) }
|
|
191
|
+
@analysed_spellings = spellings.transform_values do |list|
|
|
192
|
+
list.filter_map do |file, analysed|
|
|
193
|
+
file if analysed
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
spellings
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def respelled(use, file, role)
|
|
200
|
+
use.with(path: file, role: role, site: use.site && "#{file}:#{use.line}")
|
|
129
201
|
end
|
|
130
202
|
|
|
131
203
|
# The reference corpus is the PROJECT, not the analysed paths. A constant declared in `lib/` is commonly
|
|
@@ -133,21 +205,80 @@ module Rigor
|
|
|
133
205
|
# treating those as absent is what manufactured artifacts on two of three corpus targets in #345.
|
|
134
206
|
# Widening the *declaration* set the same way would cancel the gain (measured: +1 / −3 / +2 candidates,
|
|
135
207
|
# ADR-102 WD2), which is exactly why the two corpora are separated rather than both widened.
|
|
208
|
+
#
|
|
209
|
+
# The files are read from every {#corpus_dirs} entry (#1760): running from a subdirectory or through a
|
|
210
|
+
# symlinked spelling reads the same project.
|
|
136
211
|
def reference_files(_paths, configuration)
|
|
137
|
-
|
|
138
|
-
absolute
|
|
139
|
-
|
|
212
|
+
absolute = project_files(REFERENCE_GLOB)
|
|
213
|
+
Analysis::PathExpansion.reject_excluded(absolute, configuration.exclude_patterns,
|
|
214
|
+
base: configuration.exclude_base)
|
|
140
215
|
end
|
|
141
216
|
|
|
142
|
-
|
|
217
|
+
# The role is read from the file's realpath relative to the project root (#1760), so `test/helper.rb`
|
|
218
|
+
# is test code whichever directory the command runs from. A file outside the root reads as `../…`,
|
|
219
|
+
# which names no directory above the root.
|
|
220
|
+
def read_and_scan(file, real, configuration)
|
|
143
221
|
Analysis::Reachability::Scan.call(path: file, source: File.read(file),
|
|
144
222
|
target_ruby: configuration.target_ruby,
|
|
145
|
-
|
|
223
|
+
role: role_of(file, real))
|
|
146
224
|
rescue SystemCallError
|
|
147
225
|
nil
|
|
148
226
|
end
|
|
149
227
|
|
|
150
|
-
#
|
|
228
|
+
# Absolute paths of the project's own files matching `glob`, under every corpus directory.
|
|
229
|
+
def project_files(glob)
|
|
230
|
+
corpus_dirs.flat_map do |dir|
|
|
231
|
+
Analysis::Reachability::ProjectFiles.own(Dir.glob(glob, base: dir), dir).map { |rel| File.join(dir, rel) }
|
|
232
|
+
end.uniq
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def canonical(path)
|
|
236
|
+
File.realpath(path)
|
|
237
|
+
rescue SystemCallError
|
|
238
|
+
File.absolute_path(path)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A file's role (#1760), read from its spellings as written below the project root or the working
|
|
242
|
+
# directory: the first one below the root decides, and any of them reading as test code makes it test
|
|
243
|
+
# code, so a `features/` tree below a subdirectory run's working directory stays test code as it was when
|
|
244
|
+
# the working directory was the only root. The realpath is read only when no written spelling lies below
|
|
245
|
+
# either: a symlink whose target runs through a `test/` directory must not turn the production files
|
|
246
|
+
# reached through it into test code.
|
|
247
|
+
def role_of(file, real)
|
|
248
|
+
spellings = below(file, @root_spellings) + below(file, cwd_spellings)
|
|
249
|
+
spellings = below(real, @root_spellings) + below(real, cwd_spellings) if spellings.empty?
|
|
250
|
+
role_for = Analysis::Reachability::Scan.method(:role_for)
|
|
251
|
+
return :test if spellings.uniq.any? { |path| role_for.call(path) == :test }
|
|
252
|
+
|
|
253
|
+
role_for.call(spellings.first || relative_to_root(real))
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# `path` relative to every directory in `dirs` it lies below.
|
|
257
|
+
def below(path, dirs)
|
|
258
|
+
absolute = File.absolute_path(path)
|
|
259
|
+
dirs.filter_map { |dir| absolute.delete_prefix("#{dir.chomp('/')}/") if inside?(absolute, dir) }.uniq
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# The working directory as its realpath and, when the shell's `PWD` names it, as that logical spelling.
|
|
263
|
+
def cwd_spellings
|
|
264
|
+
@cwd_spellings ||= begin
|
|
265
|
+
logical = ENV.fetch("PWD", nil)
|
|
266
|
+
real = canonical(Dir.pwd)
|
|
267
|
+
logical && canonical(logical) == real ? [real, Dir.pwd, logical].uniq : [real, Dir.pwd].uniq
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def relative_to_root(real)
|
|
272
|
+
prefix = "#{@root.chomp('/')}/"
|
|
273
|
+
return real.delete_prefix(prefix) if real.start_with?(prefix)
|
|
274
|
+
|
|
275
|
+
Pathname.new(real).relative_path_from(Pathname.new(@root)).to_s
|
|
276
|
+
rescue ArgumentError
|
|
277
|
+
real
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# A glob is matched against every analysed spelling of the declaration's file, and each of them and its
|
|
281
|
+
# realpath relative to the project root (#1760) and to the working directory:
|
|
151
282
|
# `configuration.paths` are expanded to absolute, so a user-written `--entry-point=lib/entry.rb` would
|
|
152
283
|
# otherwise never match anything and silently produce a root set of zero.
|
|
153
284
|
# A constant named only from the project's own `sig/` is referenced — the #345 probe reported exactly this
|
|
@@ -181,8 +312,8 @@ module Rigor
|
|
|
181
312
|
names = declarations.map(&:fqn)
|
|
182
313
|
return [] if names.empty?
|
|
183
314
|
|
|
184
|
-
|
|
185
|
-
|
|
315
|
+
project_files(TEMPLATE_GLOB).flat_map do |absolute|
|
|
316
|
+
rel = relative_to_root(absolute)
|
|
186
317
|
haystack = cache.serve(:runs, absolute) { constant_bearing_text(File.read(absolute).scrub) }
|
|
187
318
|
names.filter_map do |fqn|
|
|
188
319
|
next unless haystack.include?(fqn)
|
|
@@ -213,10 +344,12 @@ module Rigor
|
|
|
213
344
|
return [] if globs.empty?
|
|
214
345
|
|
|
215
346
|
matcher = Analysis::Reachability::ProjectFiles.method(:entry_point_match?)
|
|
216
|
-
cwd = "#{File.absolute_path(Dir.pwd)}/"
|
|
217
347
|
declarations.filter_map do |d|
|
|
218
|
-
|
|
219
|
-
|
|
348
|
+
real = canonical(d.path)
|
|
349
|
+
dirs = @root_spellings + cwd_spellings
|
|
350
|
+
written = @analysed_spellings.fetch(real, [d.path]) | [d.path]
|
|
351
|
+
spellings = [*written, relative_to_root(real), *written.flat_map { |w| below(w, dirs) }, *below(real, dirs)]
|
|
352
|
+
d.fqn if globs.any? { |g| spellings.any? { |path| matcher.call(g, path) } }
|
|
220
353
|
end
|
|
221
354
|
end
|
|
222
355
|
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -51,6 +51,7 @@ module Rigor
|
|
|
51
51
|
"init" => :run_init,
|
|
52
52
|
"annotate" => :run_annotate,
|
|
53
53
|
"type-of" => :run_type_of,
|
|
54
|
+
"lens" => :run_lens,
|
|
54
55
|
"trace" => :run_trace,
|
|
55
56
|
"type-scan" => :run_type_scan,
|
|
56
57
|
"effects" => :run_effects,
|
|
@@ -291,18 +292,36 @@ module Rigor
|
|
|
291
292
|
|
|
292
293
|
# The settings `rigor init` writes: the defaults, with `test_paths:` spelled out as the test roots found now, so
|
|
293
294
|
# the project's config says where its tests are rather than leaving it to auto-detection. The roots are written
|
|
294
|
-
# relative to the config file's directory, which is what {Configuration.load} resolves them against.
|
|
295
|
+
# relative to the config file's directory, which is what {Configuration.load} resolves them against. `paths:`
|
|
296
|
+
# is written the same way: the default `lib` is resolved against the physical cwd, so a config written to
|
|
297
|
+
# `config/rigor.yml` must say `../lib`, or it loads back as `config/lib` (#1399).
|
|
295
298
|
#
|
|
296
299
|
# `target_ruby` is left out (the template writes it commented): a written key is a statement about the runtime
|
|
297
300
|
# (#1692), and the starter must not make one the user never chose.
|
|
298
301
|
def init_settings(path)
|
|
302
|
+
config_dir = config_dir_of(path)
|
|
299
303
|
settings = Configuration::DEFAULTS.except("target_ruby")
|
|
304
|
+
settings = settings.merge("paths" => roots_relative_to(settings.fetch("paths"), config_dir))
|
|
300
305
|
test_paths = Configuration.new.resolved_test_paths
|
|
301
306
|
return settings if test_paths.empty?
|
|
302
307
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
308
|
+
settings.merge("test_paths" => roots_relative_to(test_paths, config_dir))
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# The directory the config file's roots are written relative to. An absolute `--path` is taken through its
|
|
312
|
+
# physical directory: the roots are resolved against the physical cwd (`Dir.pwd` is getcwd), so a logical
|
|
313
|
+
# spelling through a symlink makes the lexical `..` climb a different tree than the kernel does (#1399 review).
|
|
314
|
+
# A relative `--path` stays lexical, as {Configuration.load} resolves it.
|
|
315
|
+
def config_dir_of(path)
|
|
316
|
+
dir = File.dirname(path)
|
|
317
|
+
Pathname(Pathname(path).absolute? ? File.realdirpath(dir) : File.absolute_path(dir))
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Each root is resolved against the physical cwd (where `rigor` reads it from), then written relative to the config
|
|
321
|
+
# dir. With the config at the project root, both sides are the same physical directory, so the roots come out as
|
|
322
|
+
# they are spelled from it (`lib`, `spec`), which is master's output for the default config.
|
|
323
|
+
def roots_relative_to(roots, config_dir)
|
|
324
|
+
roots.map { |root| Pathname(File.absolute_path(root)).relative_path_from(config_dir).to_s }
|
|
306
325
|
end
|
|
307
326
|
|
|
308
327
|
def run_annotate
|
|
@@ -317,6 +336,12 @@ module Rigor
|
|
|
317
336
|
TypeOfCommand.new(argv: @argv, out: @out, err: @err).run
|
|
318
337
|
end
|
|
319
338
|
|
|
339
|
+
def run_lens
|
|
340
|
+
require_relative "cli/lens_command"
|
|
341
|
+
|
|
342
|
+
LensCommand.new(argv: @argv, out: @out, err: @err).run
|
|
343
|
+
end
|
|
344
|
+
|
|
320
345
|
def run_trace
|
|
321
346
|
require_relative "cli/trace_command"
|
|
322
347
|
|
|
@@ -463,6 +488,7 @@ module Rigor
|
|
|
463
488
|
init Create a starter .rigor.yml
|
|
464
489
|
annotate Print FILE with each line's last-expression type
|
|
465
490
|
type-of Print inferred types at FILE:LINE[:COL] positions
|
|
491
|
+
lens Map a file's declarations with their types and provenance (experimental)
|
|
466
492
|
trace Replay how the engine typed FILE as a terminal animation
|
|
467
493
|
type-scan Report Scope#type_of coverage across PATHs
|
|
468
494
|
effects Report each method's effect labels, and the committed effect snapshot
|
data/lib/rigor/configuration.rb
CHANGED
|
@@ -356,6 +356,13 @@ module Rigor
|
|
|
356
356
|
data = DEFAULTS.merge(raw)
|
|
357
357
|
end
|
|
358
358
|
new(autowire_default_plugins(data), effects_key_present, target_ruby_key_present || false)
|
|
359
|
+
.with_exclude_base(resolved && File.exist?(resolved) ? File.dirname(resolved) : nil)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# #1576 — the directory relative `exclude:` patterns are matched against: the directory of the config file in
|
|
363
|
+
# use (the base `paths:` resolve against), or the working directory when no config file is used.
|
|
364
|
+
def exclude_base
|
|
365
|
+
@base_dir || Dir.pwd
|
|
359
366
|
end
|
|
360
367
|
|
|
361
368
|
# ADR-93 WD2 — the one bundled plugin default-wired without a `plugins:` entry. `rigor-rbs-inline` is the
|
|
@@ -746,6 +753,14 @@ module Rigor
|
|
|
746
753
|
copy.freeze
|
|
747
754
|
end
|
|
748
755
|
|
|
756
|
+
# #1576 — a sibling Configuration whose relative `exclude:` patterns match against `dir` (nil: the working
|
|
757
|
+
# directory). {Configuration.load} sets it to the loaded config file's directory.
|
|
758
|
+
def with_exclude_base(dir)
|
|
759
|
+
copy = dup
|
|
760
|
+
copy.instance_variable_set(:@base_dir, dir && File.absolute_path(dir).freeze)
|
|
761
|
+
copy.freeze
|
|
762
|
+
end
|
|
763
|
+
|
|
749
764
|
# #1777 — a sibling Configuration with only `baseline:` unset, every other field (including the
|
|
750
765
|
# explicitness of `target_ruby`) shared with the receiver. `rigor baseline generate` analyses with
|
|
751
766
|
# this so the generating run sees exactly the configuration `rigor check` will, minus the filter it
|
|
@@ -118,15 +118,19 @@ module Rigor
|
|
|
118
118
|
# `{name => LockedGem}` → set of canonical bundler gem directory basenames. Pure-Ruby gems install as
|
|
119
119
|
# `<name>-<version>`; platform-specific gems install as `<name>-<version>-<platform>` (e.g.
|
|
120
120
|
# `ffi-1.17.4-aarch64-linux-gnu`). Lockfile platform `"ruby"` is the pure-Ruby case; any other value is
|
|
121
|
-
# treated as a platform tag.
|
|
121
|
+
# treated as a platform tag. Every locked variant contributes a directory (#1844): the set only selects
|
|
122
|
+
# among directories that are actually installed, so admitting another platform's name costs nothing,
|
|
123
|
+
# while dropping the host's would discard its `sig/`.
|
|
122
124
|
def self.expected_gem_dirs(locked_gems)
|
|
123
125
|
locked_gems.each_value.with_object(Set.new) do |locked, set|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
locked.variants.each do |version, platform|
|
|
127
|
+
base = "#{locked.name}-#{version}"
|
|
128
|
+
set << if platform == "ruby" || platform.empty?
|
|
129
|
+
base
|
|
130
|
+
else
|
|
131
|
+
"#{base}-#{platform}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
130
134
|
end
|
|
131
135
|
end
|
|
132
136
|
private_class_method :expected_gem_dirs
|
|
@@ -35,11 +35,12 @@ module Rigor
|
|
|
35
35
|
found.freeze
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
# `<bundle>/ruby/X.Y.Z/gems/<name>-<version
|
|
38
|
+
# `<bundle>/ruby/X.Y.Z/gems/<name>-<version>[-<platform>]/` — the same pure-filesystem layout
|
|
39
39
|
# {MissingGemConstantIndex.bundle_gem_dirs} walks, preferred over the host's gems so a project that
|
|
40
40
|
# vendored its bundle without committing the lockfile is read from its own tree. A platform-tagged
|
|
41
|
-
# directory (`
|
|
42
|
-
#
|
|
41
|
+
# directory (`commonmarker-2.10.0-arm64-darwin`) is kept with its platform as a variant: those builds
|
|
42
|
+
# ship the same `lib/<name>.rb` and top-level namespace as the `ruby` one, and the index looks the
|
|
43
|
+
# directory up by that variant. A name installed more than once keeps every directory as a variant.
|
|
43
44
|
def from_bundle_tree(bundle_path)
|
|
44
45
|
return {} if bundle_path.nil?
|
|
45
46
|
|
|
@@ -47,10 +48,15 @@ module Rigor
|
|
|
47
48
|
return {} unless base.directory?
|
|
48
49
|
|
|
49
50
|
Dir.glob(base.join("ruby", "*", "gems", "*")).each_with_object({}) do |dir, acc|
|
|
50
|
-
name, version =
|
|
51
|
+
name, version, platform = split_install_dirname(File.basename(dir))
|
|
51
52
|
next unless name
|
|
52
53
|
|
|
53
|
-
acc[name]
|
|
54
|
+
prior = acc[name]
|
|
55
|
+
acc[name] = if prior
|
|
56
|
+
prior.with(variants: [*prior.variants, [version, platform]])
|
|
57
|
+
else
|
|
58
|
+
locked_gem(name, version, platform)
|
|
59
|
+
end
|
|
54
60
|
end
|
|
55
61
|
end
|
|
56
62
|
|
|
@@ -70,15 +76,36 @@ module Rigor
|
|
|
70
76
|
{}
|
|
71
77
|
end
|
|
72
78
|
|
|
73
|
-
def locked_gem(name, version)
|
|
74
|
-
LockfileResolver::LockedGem.new(name: name, version: version, platform:
|
|
79
|
+
def locked_gem(name, version, platform = "ruby")
|
|
80
|
+
LockfileResolver::LockedGem.new(name: name, version: version, platform: platform)
|
|
75
81
|
end
|
|
76
82
|
|
|
77
|
-
# Splits a gem install directory's basename
|
|
78
|
-
#
|
|
79
|
-
|
|
83
|
+
# Splits a gem install directory's basename into `[name, version, platform]`, or nil. The version
|
|
84
|
+
# always starts with a digit and carries no hyphen; a platform tail is accepted only when RubyGems
|
|
85
|
+
# recognises its OS (`arm64-darwin`, `x86_64-linux-gnu`, `java`), so `foo-2fa-1.0` stays the plain gem
|
|
86
|
+
# `foo-2fa`. The platform split is tried first, because a plain split of `foo-1.0-universal-darwin-23`
|
|
87
|
+
# would claim the name `foo-1.0-universal-darwin`; and from the rightmost version-shaped segment,
|
|
88
|
+
# because RubyGems also reads `1.0-x86_64-linux` as a linux platform, which would cut
|
|
89
|
+
# `foo-2fa-1.0-x86_64-linux` at `foo`.
|
|
90
|
+
def split_install_dirname(basename)
|
|
91
|
+
platform_split(basename) || plain_split(basename)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def platform_split(basename)
|
|
95
|
+
cuts = []
|
|
96
|
+
basename.scan(/-(\d[^-]*)(?=-.)/) { cuts << Regexp.last_match }
|
|
97
|
+
cuts.reverse_each do |match|
|
|
98
|
+
platform = match.post_match.delete_prefix("-")
|
|
99
|
+
# `current` makes `Gem::Platform.new` read the host's platform, which a non-main Ractor may not touch.
|
|
100
|
+
next if platform == "current"
|
|
101
|
+
return [match.pre_match, match[1], platform] unless Gem::Platform.new(platform).os == "unknown"
|
|
102
|
+
end
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def plain_split(basename)
|
|
80
107
|
match = /\A(.+)-(\d[^-]*)\z/.match(basename)
|
|
81
|
-
match ? [match[1], match[2]] : nil
|
|
108
|
+
match ? [match[1], match[2], "ruby"] : nil
|
|
82
109
|
end
|
|
83
110
|
end
|
|
84
111
|
end
|
|
@@ -30,13 +30,29 @@ module Rigor
|
|
|
30
30
|
# dependency that moved from a `git:` source to a released version without a `bundle clean`, so its
|
|
31
31
|
# git-installed directory is still sitting there under the OLD source's name). Defaults `false` so
|
|
32
32
|
# existing callers that construct a `LockedGem` without an opinion stay conservative.
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
#
|
|
34
|
+
# `variants` is every `[version, platform]` pair the lockfile locks under this name, in Bundler's parsed
|
|
35
|
+
# order. A multi-platform lockfile (`bundle lock --add-platform`) lists one spec per platform — `ffi
|
|
36
|
+
# (1.17.4)`, `ffi (1.17.4-arm64-darwin)`, … — and Bundler installs whichever matches the host, so
|
|
37
|
+
# {BundleSigDiscovery} must admit all of them (#1844). `version` / `platform` name one representative
|
|
38
|
+
# variant for the readers that want a single version (coverage report, doctor): the `ruby` one when the
|
|
39
|
+
# lockfile has it, otherwise the last parsed. Defaults to the single `[version, platform]` pair, and the
|
|
40
|
+
# pair must be one of `variants`.
|
|
41
|
+
LockedGem = Data.define(:name, :version, :platform, :git_source, :variants) do
|
|
42
|
+
def initialize(name:, version:, platform:, git_source: false, variants: nil)
|
|
43
|
+
version = -version.to_s
|
|
44
|
+
platform = -platform.to_s
|
|
45
|
+
variants = (variants || [[version, platform]]).map { |v, p| [-v.to_s, -p.to_s].freeze }.uniq.freeze
|
|
46
|
+
unless variants.include?([version, platform])
|
|
47
|
+
raise ArgumentError, "LockedGem #{name}: [#{version}, #{platform}] is not one of its variants"
|
|
48
|
+
end
|
|
49
|
+
|
|
35
50
|
super(
|
|
36
51
|
name: -name.to_s,
|
|
37
|
-
version:
|
|
38
|
-
platform:
|
|
39
|
-
git_source: git_source ? true : false
|
|
52
|
+
version: version,
|
|
53
|
+
platform: platform,
|
|
54
|
+
git_source: git_source ? true : false,
|
|
55
|
+
variants: variants
|
|
40
56
|
)
|
|
41
57
|
end
|
|
42
58
|
end
|
|
@@ -162,10 +178,13 @@ module Rigor
|
|
|
162
178
|
# `#source` is the `Bundler::Source::*` this spec resolved from — `Git` for a `GIT` lockfile
|
|
163
179
|
# stanza, `Rubygems` for a `GEM` one, `Path`/`Gemspec` for `path:`. Only `Git` sources land under
|
|
164
180
|
# {BundleSigDiscovery}'s `bundler/gems/` walk.
|
|
165
|
-
|
|
181
|
+
# A multi-platform lockfile repeats the name once per platform; fold the repeats into `variants`
|
|
182
|
+
# instead of letting the last one overwrite the rest (#1844).
|
|
183
|
+
entry = LockedGem.new(
|
|
166
184
|
name: spec.name, version: spec.version.to_s, platform: platform,
|
|
167
185
|
git_source: spec.source.is_a?(Bundler::Source::Git)
|
|
168
186
|
)
|
|
187
|
+
h[entry.name] = (prior = h[entry.name]) ? merge_variant(prior, entry) : entry
|
|
169
188
|
end
|
|
170
189
|
locked.freeze
|
|
171
190
|
rescue ::Ractor::IsolationError => e
|
|
@@ -177,6 +196,18 @@ module Rigor
|
|
|
177
196
|
end
|
|
178
197
|
private_class_method :do_parse
|
|
179
198
|
|
|
199
|
+
# Folds a further platform variant of an already-parsed name into it. The representative keeps a `ruby`
|
|
200
|
+
# variant once seen; otherwise the last variant wins, as it did before variants were kept.
|
|
201
|
+
def self.merge_variant(prior, entry)
|
|
202
|
+
rep = prior.platform == "ruby" ? prior : entry
|
|
203
|
+
prior.with(
|
|
204
|
+
variants: [*prior.variants, [entry.version, entry.platform]],
|
|
205
|
+
version: rep.version, platform: rep.platform,
|
|
206
|
+
git_source: prior.git_source || entry.git_source
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
private_class_method :merge_variant
|
|
210
|
+
|
|
180
211
|
# #1064 — Bundler's lockfile parser reads RubyGems module state a non-main Ractor may not touch, so the
|
|
181
212
|
# parse raises `Ractor::IsolationError` there whatever the lockfile says. That is an engine constraint, not
|
|
182
213
|
# a malformed lockfile, and the warning must not send the user to fix a file that is fine. The Ractor pool
|
|
@@ -37,8 +37,8 @@ module Rigor
|
|
|
37
37
|
module MissingGemConstantIndex
|
|
38
38
|
module_function
|
|
39
39
|
|
|
40
|
-
# @param gems — `[gem_name, version]`
|
|
41
|
-
#
|
|
40
|
+
# @param gems — `[gem_name, version, variants]` entries (the `:missing` rows of {RbsCoverageReport},
|
|
41
|
+
# with the gem's `LockedGem#variants`); `variants` may be omitted or nil, meaning the `ruby` one.
|
|
42
42
|
# @param bundle_path — the target's resolved bundler install root, or nil.
|
|
43
43
|
# @param spec_resolver — `(name, version) -> String?` fallback dir resolver. Injectable for
|
|
44
44
|
# specs; the default is the RubyGems-metadata lookup (no code load).
|
|
@@ -49,8 +49,9 @@ module Rigor
|
|
|
49
49
|
bundle_dirs = bundle_gem_dirs(bundle_path)
|
|
50
50
|
git_dirs = git_gem_dirs(bundle_path)
|
|
51
51
|
index = {}
|
|
52
|
-
gems.each do |gem_name, version|
|
|
53
|
-
dir = bundle_dirs
|
|
52
|
+
gems.each do |gem_name, version, variants|
|
|
53
|
+
dir = bundle_dir(bundle_dirs, gem_name, version, variants) ||
|
|
54
|
+
git_dirs[gem_name] || spec_resolver.call(gem_name, version)
|
|
54
55
|
next unless dir
|
|
55
56
|
|
|
56
57
|
entry_files(dir, gem_name).each do |file|
|
|
@@ -62,9 +63,8 @@ module Rigor
|
|
|
62
63
|
|
|
63
64
|
# `{"<name>-<version>" => gem_dir}` for the target's bundle, or `{}` when no bundle is resolvable. The
|
|
64
65
|
# glob covers the RUBYGEMS-sourced layout only, `<bundle>/ruby/X.Y.Z/gems/<name>-<version>/`; the
|
|
65
|
-
# git-sourced layout is {.git_gem_dirs}'s. Keyed on the dir basename
|
|
66
|
-
#
|
|
67
|
-
# native code, not the pure-Ruby constants this indexes.
|
|
66
|
+
# git-sourced layout is {.git_gem_dirs}'s. Keyed on the dir basename, which {.bundle_dir} reconstructs
|
|
67
|
+
# per locked variant.
|
|
68
68
|
def bundle_gem_dirs(bundle_path)
|
|
69
69
|
return {} if bundle_path.nil?
|
|
70
70
|
|
|
@@ -76,6 +76,21 @@ module Rigor
|
|
|
76
76
|
end
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
# The installed directory of one of the gem's locked variants: `<name>-<version>` for the `ruby` one,
|
|
80
|
+
# `<name>-<version>-<platform>` for a platform-tagged one. Bundler installs only the variant matching
|
|
81
|
+
# the host, and a multi-platform lockfile's representative is usually the `ruby` one, so every variant
|
|
82
|
+
# is tried. A platform-tagged gem is not a native-only gem: the commonmarker, nokogiri and sqlite3 builds
|
|
83
|
+
# each ship `lib/<name>.rb` declaring the gem's top-level namespace, exactly as the `ruby` build does.
|
|
84
|
+
def bundle_dir(bundle_dirs, gem_name, version, variants)
|
|
85
|
+
[[version, "ruby"], *variants].each do |variant_version, platform|
|
|
86
|
+
basename = "#{gem_name}-#{variant_version}"
|
|
87
|
+
basename = "#{basename}-#{platform}" unless platform == "ruby"
|
|
88
|
+
dir = bundle_dirs[basename]
|
|
89
|
+
return dir if dir
|
|
90
|
+
end
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
79
94
|
# Issue #763 — `{gem_name => gem_dir}` for the target's GIT-sourced gems,
|
|
80
95
|
# `<bundle>/ruby/X.Y.Z/bundler/gems/<repo>-<12-hex-revision>/`. Before this, a `git:`-sourced gem was
|
|
81
96
|
# invisible to the index, so every constant reaching into it recorded the generic engine-gap cause
|
|
@@ -2321,6 +2321,39 @@ module Rigor
|
|
|
2321
2321
|
[].freeze
|
|
2322
2322
|
end
|
|
2323
2323
|
|
|
2324
|
+
# Issue #1752 — the lookup of `class_name`'s class object as RBS declares it, the singleton twin of
|
|
2325
|
+
# {#ancestor_names_for}: `[:singleton, name]` for each singleton class on it (`C`'s, then each superclass's)
|
|
2326
|
+
# and `[:instance, name]` for each module or class whose instance methods it reaches (the declared `extend`s,
|
|
2327
|
+
# then `Class`, `Module`, `Object`, `Kernel` and `BasicObject` for a class, `Module` onwards for a module), in
|
|
2328
|
+
# lookup order. A refinement of `Object` or `Module` answers a call on a class object through the second kind.
|
|
2329
|
+
#
|
|
2330
|
+
# Marked as an internal demand, as {#ancestor_names_for} is (issue #696). Memoised per name: the environment is
|
|
2331
|
+
# immutable for the loader's lifetime.
|
|
2332
|
+
#
|
|
2333
|
+
# @return `::`-stripped pairs, or `[]` for an unknown class and on any RBS build error.
|
|
2334
|
+
def singleton_ancestors_for(class_name)
|
|
2335
|
+
key = class_name.to_s.delete_prefix("::")
|
|
2336
|
+
memo = (@state[:singleton_ancestors] ||= {})
|
|
2337
|
+
return memo[key] if memo.key?(key)
|
|
2338
|
+
|
|
2339
|
+
memo[key] = build_singleton_ancestors(key)
|
|
2340
|
+
end
|
|
2341
|
+
|
|
2342
|
+
def build_singleton_ancestors(key)
|
|
2343
|
+
during_internal_demand do
|
|
2344
|
+
definition = singleton_definition(key)
|
|
2345
|
+
next [].freeze if definition.nil?
|
|
2346
|
+
|
|
2347
|
+
definition.ancestors.ancestors.map do |ancestor|
|
|
2348
|
+
kind = ancestor.is_a?(::RBS::Definition::Ancestor::Singleton) ? :singleton : :instance
|
|
2349
|
+
[kind, ancestor.name.to_s.delete_prefix("::")].freeze
|
|
2350
|
+
end.uniq.freeze
|
|
2351
|
+
end
|
|
2352
|
+
rescue ::RBS::BaseError, StandardError
|
|
2353
|
+
[].freeze
|
|
2354
|
+
end
|
|
2355
|
+
private :build_singleton_ancestors
|
|
2356
|
+
|
|
2324
2357
|
# @return the class method on `class_name`. For example,
|
|
2325
2358
|
# `singleton_method(class_name: "Integer", method_name: :sqrt)` returns the definition for
|
|
2326
2359
|
# `Integer.sqrt`, while `singleton_method(class_name: "Foo", method_name: :new)` returns Class#new
|
|
@@ -51,7 +51,8 @@ module Rigor
|
|
|
51
51
|
#
|
|
52
52
|
# @param sources — path => source text, read in place of that path's file (an editor buffer).
|
|
53
53
|
def for_configuration(configuration, extra_files = [], sources: {})
|
|
54
|
-
files = Analysis::PathExpansion.ruby_files(configuration.paths, configuration.exclude_patterns
|
|
54
|
+
files = Analysis::PathExpansion.ruby_files(configuration.paths, configuration.exclude_patterns,
|
|
55
|
+
base: configuration.exclude_base)
|
|
55
56
|
scan(files + Array(extra_files).map(&:to_s), sources: sources)
|
|
56
57
|
rescue StandardError
|
|
57
58
|
[]
|
|
@@ -77,7 +78,8 @@ module Rigor
|
|
|
77
78
|
# @return path (absolute) => the features that file requires, for every file under the configured paths
|
|
78
79
|
# that requires any. A long-lived reader (the language server) keeps this and overlays its open buffers.
|
|
79
80
|
def feature_files(configuration)
|
|
80
|
-
listed = Analysis::PathExpansion.ruby_files(configuration.paths, configuration.exclude_patterns
|
|
81
|
+
listed = Analysis::PathExpansion.ruby_files(configuration.paths, configuration.exclude_patterns,
|
|
82
|
+
base: configuration.exclude_base)
|
|
81
83
|
files = listed.map { |path| File.expand_path(path.to_s) }
|
|
82
84
|
result = files.each_with_object({}) do |path, acc|
|
|
83
85
|
features = memoized_features_in(path)
|