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.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +12 -5
  4. data/docs/manual/02-cli-reference.md +19 -1
  5. data/docs/manual/03-configuration.md +6 -4
  6. data/docs/manual/05-inspecting-types.md +50 -0
  7. data/docs/manual/12-caching.md +18 -1
  8. data/docs/manual/14-rails-quickstart.md +2 -2
  9. data/docs/manual/18-removing-dead-code.md +16 -9
  10. data/lib/rigor/analysis/check_rules/rule_ids.rb +7 -0
  11. data/lib/rigor/analysis/check_rules.rb +23 -22
  12. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  13. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  14. data/lib/rigor/analysis/incremental_session.rb +89 -22
  15. data/lib/rigor/analysis/path_expansion.rb +62 -6
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  17. data/lib/rigor/analysis/reachability/graph.rb +119 -15
  18. data/lib/rigor/analysis/reachability/scan.rb +332 -173
  19. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  20. data/lib/rigor/analysis/run_cache_probe.rb +1 -1
  21. data/lib/rigor/analysis/run_stats.rb +28 -6
  22. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +26 -1
  23. data/lib/rigor/analysis/runner/pool_coordinator.rb +28 -9
  24. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
  25. data/lib/rigor/analysis/runner.rb +29 -11
  26. data/lib/rigor/analysis/worker_session.rb +1 -1
  27. data/lib/rigor/cache/engine_source.rb +21 -7
  28. data/lib/rigor/cache/incremental_snapshot.rb +36 -8
  29. data/lib/rigor/cli/check_command.rb +138 -39
  30. data/lib/rigor/cli/docs_command.rb +5 -4
  31. data/lib/rigor/cli/lens_command.rb +144 -0
  32. data/lib/rigor/cli/lsp_command.rb +5 -2
  33. data/lib/rigor/cli/skill_command.rb +3 -3
  34. data/lib/rigor/cli/skill_describe.rb +1 -1
  35. data/lib/rigor/cli/type_of_command.rb +2 -1
  36. data/lib/rigor/cli/unused_command.rb +157 -24
  37. data/lib/rigor/cli.rb +30 -4
  38. data/lib/rigor/configuration.rb +15 -0
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  40. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  41. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  42. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  43. data/lib/rigor/environment/rbs_loader.rb +33 -0
  44. data/lib/rigor/environment/required_features.rb +4 -2
  45. data/lib/rigor/environment.rb +12 -5
  46. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  47. data/lib/rigor/inference/captured_locals.rb +43 -3
  48. data/lib/rigor/inference/expression_typer.rb +58 -25
  49. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  50. data/lib/rigor/inference/match_rebinding.rb +1 -1
  51. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  52. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  53. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  54. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  55. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  56. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  57. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  58. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  59. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  60. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  61. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  62. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  63. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  64. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  65. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  66. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  67. data/lib/rigor/inference/repeated_or_writes.rb +2 -0
  68. data/lib/rigor/inference/scope_indexer.rb +140 -5
  69. data/lib/rigor/inference/statement_evaluator.rb +223 -31
  70. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  71. data/lib/rigor/language_server/project_context.rb +56 -5
  72. data/lib/rigor/lens/anchor.rb +115 -0
  73. data/lib/rigor/lens/contract_reader.rb +118 -0
  74. data/lib/rigor/lens/document.rb +113 -0
  75. data/lib/rigor/lens/file_reader.rb +433 -0
  76. data/lib/rigor/lens/members.rb +153 -0
  77. data/lib/rigor/lens/parameters.rb +91 -0
  78. data/lib/rigor/lens/plugins.rb +40 -0
  79. data/lib/rigor/lens/run.rb +280 -0
  80. data/lib/rigor/lens/slot.rb +79 -0
  81. data/lib/rigor/lens.rb +40 -0
  82. data/lib/rigor/project_environment.rb +20 -1
  83. data/lib/rigor/protection/mutation_cache.rb +2 -1
  84. data/lib/rigor/scope.rb +22 -11
  85. data/lib/rigor/sig_gen/generator.rb +45 -5
  86. data/lib/rigor/version.rb +1 -1
  87. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  88. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  89. data/sig/rigor/scope.rbs +3 -0
  90. data/sig/rigor.rbs +6 -3
  91. data/skills/rigor-project-init/references/02-configure.md +2 -2
  92. 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
- scan_cache = Analysis::Reachability::ScanCache.open(configuration.cache_path,
53
- target_ruby: configuration.target_ruby)
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
- declaration_files = Analysis::PathExpansion.ruby_files(paths, configuration.exclude_patterns).to_set
116
- declarations = []
117
- shadows = []
118
- references = []
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
- (declaration_files.include?(file) ? declarations : shadows).concat(result.declarations)
125
- references.concat(result.references)
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
- [declarations, references, dynamic_uses, shadows]
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
- relative = Analysis::Reachability::ProjectFiles.own(Dir.glob(REFERENCE_GLOB, base: Dir.pwd), Dir.pwd)
138
- absolute = relative.map { |rel| File.absolute_path(rel) }
139
- Analysis::PathExpansion.reject_excluded(absolute, configuration.exclude_patterns).to_set
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
- def read_and_scan(file, configuration)
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
- root: File.absolute_path(Dir.pwd))
223
+ role: role_of(file, real))
146
224
  rescue SystemCallError
147
225
  nil
148
226
  end
149
227
 
150
- # A glob is matched against the declaration's path AND its path relative to the working directory:
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
- Analysis::Reachability::ProjectFiles.own(Dir.glob(TEMPLATE_GLOB, base: Dir.pwd), Dir.pwd).flat_map do |rel|
185
- absolute = File.absolute_path(rel)
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
- relative = d.path.delete_prefix(cwd)
219
- d.fqn if globs.any? { |g| matcher.call(g, d.path) || matcher.call(g, relative) }
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
- config_dir = Pathname(File.absolute_path(File.dirname(path)))
304
- relative = test_paths.map { |root| Pathname(root).relative_path_from(config_dir).to_s }
305
- settings.merge("test_paths" => relative)
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
@@ -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
- base = "#{locked.name}-#{locked.version}"
125
- set << if locked.platform == "ruby" || locked.platform.empty?
126
- base
127
- else
128
- "#{base}-#{locked.platform}"
129
- end
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>/` — the same pure-filesystem layout
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 (`ffi-1.17.4-aarch64-linux-gnu`) does not split into a name/version pair and is skipped:
42
- # those gems ship native code, not the pure-Ruby constants this feeds.
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 = split_name_version(File.basename(dir))
51
+ name, version, platform = split_install_dirname(File.basename(dir))
51
52
  next unless name
52
53
 
53
- acc[name] ||= locked_gem(name, version)
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: "ruby")
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. The version tail is anchored on a leading digit and must
78
- # carry no further hyphen, which is what excludes the platform-tagged variants above.
79
- def split_name_version(basename)
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
- LockedGem = Data.define(:name, :version, :platform, :git_source) do
34
- def initialize(name:, version:, platform:, git_source: false)
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: -version.to_s,
38
- platform: -platform.to_s,
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
- h[spec.name.to_s] = LockedGem.new(
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]` pairs (the `:missing` rows of
41
- # {RbsCoverageReport}).
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["#{gem_name}-#{version}"] || git_dirs[gem_name] || spec_resolver.call(gem_name, version)
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 so a platform-tagged variant
66
- # (`ffi-1.17.4-aarch64-linux-gnu`) simply doesn't match a `<name>-<version>` lookup — those gems ship
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)