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
@@ -51,10 +51,14 @@ module Rigor
51
51
  # - `:leading` — `"V#{version}"` is the START of a name, not a namespace, so it can construct `V1_0`
52
52
  # and `V2_2` as well as anything beneath them: a string-prefix match (#1734).
53
53
  #
54
- # `within` is set for `Foo.const_get("Bar::#{k}")`: `prefix` is then relative to the receiver, a
55
- # {Reference} that {Graph} resolves as it resolves any other before matching.
56
- DynamicUse = Data.define(:name, :prefix, :reason, :site, :path, :line, :scope, :within) do
57
- def initialize(scope: :namespace, within: nil, **) = super
54
+ # `within` is set for a `const_get` on a known receiver — `Foo.const_get("Bar::#{k}")`, or an implicit
55
+ # receiver, `self` or `self.class`, which is the enclosing declaration written rooted: `prefix` is then
56
+ # relative to the receiver, a {Reference} that {Graph} resolves as it resolves any other before
57
+ # matching. A literal `name` keeps `within` too, and {Graph} looks it up from there (#1761). `inherit`
58
+ # is false when the call passes `inherit = false`, which confines the lookup to the receiver itself;
59
+ # otherwise the receiver's ancestors are searched as well. `role` is the referring file's (WD8).
60
+ DynamicUse = Data.define(:name, :prefix, :reason, :site, :path, :line, :scope, :within, :inherit, :role) do
61
+ def initialize(scope: :namespace, within: nil, inherit: true, role: :production, **) = super
58
62
 
59
63
  # Whether this site's evidence reaches `fqn`.
60
64
  def taints?(fqn)
@@ -65,18 +69,28 @@ module Rigor
65
69
  fqn == prefix || fqn.start_with?("#{prefix}::")
66
70
  end
67
71
 
68
- # This site with `within` resolved by the block into absolute `prefix`es. A resolution of the whole
72
+ # The namespaces an interpolated site's `within` names, resolved by the block. A resolution of the whole
69
73
  # written path is the anchor. {Graph#resolve} peels an unknown `Foo::Bar` to `Foo`, though, and
70
- # anchoring there alone looked for `Foo::V*` and missed `Foo::Bar::V1`; the written name is then the
71
- # anchor as well as the peeled one, since an undeclared receiver may be an alias of either. A
72
- # receiver that does not resolve at all keeps its written name.
73
- def anchored
74
- return [self] if within.nil?
74
+ # anchoring there alone looked for `Foo::V*` and missed `Foo::Bar::V1`; the written name is then an
75
+ # anchor as well as the peeled one, since an undeclared receiver may be an alias of either. A receiver
76
+ # that does not resolve at all keeps its written name. Over-reaching is safe for a taint only; a
77
+ # literal anchors at {#receiver} alone.
78
+ def anchors
79
+ resolved = yield(within)
80
+ whole?(resolved) ? [resolved] : [within.as_written, resolved].compact
81
+ end
75
82
 
76
- written = within.as_written
83
+ # The declaration `within` names when it resolves as WHOLE, or nil. A receiver resolved only by peeling
84
+ # its last segment is unknown: `const_get` never searches the receiver's lexical parent, so anchoring a
85
+ # literal there could resolve it to a dead constant and miss the live one.
86
+ def receiver
77
87
  resolved = yield(within)
78
- anchors = resolved == written || resolved&.end_with?("::#{written}") ? [resolved] : [written, resolved]
79
- anchors.compact.map { |anchor| with(prefix: "#{anchor}::#{prefix}", within: nil) }
88
+ whole?(resolved) ? resolved : nil
89
+ end
90
+
91
+ def whole?(resolved)
92
+ written = within.as_written
93
+ resolved == written || resolved&.end_with?("::#{written}")
80
94
  end
81
95
  end
82
96
 
@@ -128,7 +142,8 @@ module Rigor
128
142
  # @param root — the project root `path` is made relative to when its role is decided.
129
143
  # @return nil when the file does not parse (a parse error is the analyzer's business, not
130
144
  # this scan's — it simply contributes nothing rather than half a file).
131
- def self.call(path:, source:, target_ruby: nil, root: nil)
145
+ # `role`, when given, is the referring file's role, decided by the caller instead of from `path` (#1760).
146
+ def self.call(path:, source:, target_ruby: nil, root: nil, role: nil)
132
147
  parsed = if target_ruby
133
148
  Prism.parse(source, filepath: path,
134
149
  version: target_ruby)
@@ -137,7 +152,7 @@ module Rigor
137
152
  end
138
153
  return nil unless parsed.success?
139
154
 
140
- walker = Walker.new(path: path, role: role_for(path, root: root))
155
+ walker = Walker.new(path: path, role: role || role_for(path, root: root))
141
156
  walker.walk(parsed.value, [])
142
157
  Result.new(declarations: walker.declarations.freeze, references: walker.references.freeze,
143
158
  dynamic_uses: walker.dynamic_uses.freeze)
@@ -197,8 +212,254 @@ module Rigor
197
212
  end
198
213
  end
199
214
 
215
+ # `Const = Class.new` / `Module.new` / `Data.define(...)` / `Struct.new(...)` declare a class under a
216
+ # constant, and `ScopeIndexer#record_class_new_constant_decl` already treats them as class declarations
217
+ # for cross-file resolution. The report must agree, or every such constant reads as an unreferenced
218
+ # value rather than a class.
219
+ META_NEW = { "Class" => :new, "Module" => :new, "Data" => :define, "Struct" => :new }.freeze
220
+ private_constant :META_NEW
221
+
222
+ # ADR-102 WD4 — the {Walker}'s reading of dynamic constant resolution: `constantize` and `const_get`
223
+ # sites, the receivers a `const_get` looks its name up from, and the values its argument can hold.
224
+ module DynamicSites
225
+ private
226
+
227
+ # Names that turn a String into a constant. `constantize` / `safe_constantize` are ActiveSupport;
228
+ # `const_get` is core and may carry an explicit receiver (`Object.const_get`, `self.class.const_get`).
229
+ DYNAMIC_RESOLVERS = %i[constantize safe_constantize const_get].freeze
230
+ private_constant :DYNAMIC_RESOLVERS
231
+
232
+ # The anchor of a lookup that starts at the top level.
233
+ TOP_LEVEL = ""
234
+ private_constant :TOP_LEVEL
235
+
236
+ SUBJECT_SHAPES = {
237
+ Prism::StringNode => "a literal string",
238
+ Prism::SymbolNode => "a literal symbol",
239
+ Prism::InterpolatedStringNode => "an interpolated string",
240
+ Prism::InterpolatedSymbolNode => "an interpolated symbol"
241
+ }.freeze
242
+ private_constant :SUBJECT_SHAPES
243
+
244
+ # Calls that hand a string or symbol through unchanged, as far as the name it spells: `"V#{v}".freeze`.
245
+ PASS_THROUGH = %i[freeze to_s to_str to_sym dup -@ +@].freeze
246
+ private_constant :PASS_THROUGH
247
+
248
+ # Blocks whose `self` is not the enclosing declaration: a concern's `included` / `class_methods` body
249
+ # runs on the including class, and an eval block on its receiver.
250
+ SELF_REBINDING = %i[included prepended extended class_methods class_eval module_eval class_exec
251
+ module_exec instance_eval instance_exec].freeze
252
+ private_constant :SELF_REBINDING
253
+
254
+ # A call is a dynamic-resolution site, and its block may run with another `self` ({#rebinds_self?}).
255
+ def record_call(node, nesting)
256
+ record_dynamic_use(node, nesting)
257
+ @rebinding_blocks << node.block if node.block.is_a?(Prism::BlockNode) && rebinds_self?(node)
258
+ end
259
+
260
+ # Rigor knows the argument's shape, which is the whole reason this can be tiered rather than treated
261
+ # as a blanket namespace poison: a literal argument names the exact constant and is as good as a
262
+ # written reference, while an interpolated one can only bound the names its literal head reaches.
263
+ #
264
+ # A local variable argument is read through its assignments in the same local scope, because the
265
+ # name is usually built one line up: GitLab's `Migration[2.2]` is `name = "V#{...}"` followed by
266
+ # `const_get(name, false)` (#1734).
267
+ def record_dynamic_use(node, nesting)
268
+ return unless DYNAMIC_RESOLVERS.include?(node.name)
269
+
270
+ subject = node.name == :const_get ? node.arguments&.arguments&.first : node.receiver
271
+ return if subject.nil?
272
+
273
+ anchors = node.name == :const_get ? const_get_anchors(node, nesting) : [TOP_LEVEL]
274
+ subjects(subject).each { |value| record_dynamic_subject(node, value, anchors) }
275
+ end
276
+
277
+ def record_dynamic_subject(node, subject, anchors)
278
+ name, head = dynamic_target(subject)
279
+ # A literal whose bytes are not valid UTF-8 cannot name a constant; dropping it is the only safe
280
+ # answer, and carrying it forward crashed the whole run downstream.
281
+ return if subject.is_a?(Prism::StringNode) && name.nil?
282
+ return if subject.is_a?(Prism::SymbolNode) && name.nil?
283
+
284
+ reason = "#{node.name} on #{SUBJECT_SHAPES.fetch(subject.class, 'a computed value')}"
285
+ bounds = if name then literal_bounds(node, name, anchors)
286
+ elsif head then anchors.filter_map { |anchor| bound(head, anchor) }.uniq
287
+ else []
288
+ end
289
+ bounds = [[nil, :namespace, nil]] if bounds.empty?
290
+ bounds.each do |prefix, scope, within|
291
+ @dynamic_uses << DynamicUse.new(name: name, prefix: prefix, scope: scope, within: within,
292
+ inherit: anchors.include?(TOP_LEVEL), role: @role,
293
+ site: site(node), path: @path, line: node.location.start_line,
294
+ reason: reason)
295
+ end
296
+ end
297
+
298
+ # A literal name is looked up from each receiver the call can have, so it carries the receiver as
299
+ # `within` and {Graph} resolves it there first (#1761). A rooted name, or a call whose only anchor is
300
+ # the top level, is looked up from the top level as any reference is.
301
+ #
302
+ # On `self` the receiver is the enclosing declaration only where the walk can name it ({#self_lookup}).
303
+ # Where `self` is a `Class` or a singleton class, Ruby looks the name up at the top level. Where the
304
+ # walk cannot tell what `self` is, the name is read BOTH at the enclosing declaration and at the top
305
+ # level: either reading alone lists a live constant as a candidate when it is the wrong one, and a
306
+ # dead constant kept reachable is the cheaper mistake. An interpolated name keeps every anchor, since
307
+ # a taint is safe to over-reach.
308
+ def literal_bounds(node, name, anchors)
309
+ return [] if name.start_with?("::")
310
+
311
+ within = anchors.grep(Reference).map { |receiver| [nil, :namespace, receiver] }
312
+ return within unless self_receiver?(node.receiver)
313
+
314
+ case self_lookup(node.receiver)
315
+ when :declaration then within
316
+ when :top_level then []
317
+ else within + [[nil, :namespace, nil]]
318
+ end
319
+ end
320
+
321
+ # Where a `const_get` on `self` (implicit, `self`, or `self.class`) looks its name up: `:declaration`
322
+ # when `self` is the enclosing class or module, `:top_level` when it is `Class` or a singleton class,
323
+ # `:unknown` otherwise. A block's `self` is unknown — `included do`, `class_eval` and an instance-
324
+ # evaluated DSL block (`configure { }`, an RSpec `describe` body) all rebind it, and the walk cannot
325
+ # tell those from an `each` block. So is the `self` of a `def Other.x` or of a `class << Other` body.
326
+ def self_lookup(receiver)
327
+ return :unknown if @in_block || @self_rebound
328
+
329
+ class_of_self = receiver.is_a?(Prism::CallNode)
330
+ case @self_kind
331
+ when :declaration, :singleton_method then class_of_self ? :top_level : :declaration
332
+ when :instance_method then class_of_self ? :declaration : :unknown
333
+ when :singleton_class then :top_level
334
+ else :unknown
335
+ end
336
+ end
337
+
338
+ # Where a `const_get` looks a name up, as the namespaces its interpolated argument is bounded by. The
339
+ # receiver's namespace comes first: an implicit receiver, `self` or `self.class` is the enclosing
340
+ # declaration, and a constant receiver is resolved by {Graph} as any reference is (`within`). With
341
+ # the default `inherit = true` the lookup also falls through to the top level, which is all the
342
+ # reading had before, so that stays tainted too; an explicit `false` confines it to the receiver. An
343
+ # unknown receiver bounds nothing beyond the top level.
344
+ def const_get_anchors(node, nesting)
345
+ receiver = node.receiver
346
+ namespaces =
347
+ if self_receiver?(receiver)
348
+ self_references(node, nesting)
349
+ elsif constant_receiver?(receiver)
350
+ [receiver_reference(receiver, nesting)]
351
+ else
352
+ []
353
+ end
354
+ return [TOP_LEVEL] if namespaces.empty?
355
+
356
+ inherit = node.arguments.arguments[1]
357
+ inherit.is_a?(Prism::FalseNode) ? namespaces : namespaces + [TOP_LEVEL]
358
+ end
359
+
360
+ # The enclosing declaration as a rooted {Reference}, so {Graph} anchors it as it anchors a constant
361
+ # receiver and can search its ancestors. Inside a meta-new rvalue `self` is the declared class in its
362
+ # block but the outer scope in its arguments; the walk does not tell the two apart, so both are taken.
363
+ def self_references(node, nesting)
364
+ ([@owner, nesting.join("::")].compact.uniq - [TOP_LEVEL]).map do |fqn|
365
+ Reference.new(as_written: fqn, nesting: [].freeze, from: nil, role: @role, path: @path,
366
+ line: node.location.start_line, rooted: true)
367
+ end
368
+ end
369
+
370
+ # Whether `call`'s block runs with a `self` other than the enclosing declaration's. A meta-new block
371
+ # assigned to a constant is that constant ({#walk_constant_write} credits it as `@owner`); an
372
+ # anonymous one is not the enclosing declaration either.
373
+ def rebinds_self?(call)
374
+ return true if SELF_REBINDING.include?(call.name)
375
+
376
+ recv = call.receiver
377
+ name = recv && Source::ConstantPath.qualified_name_or_nil(recv)&.delete_prefix("::")
378
+ @owner.nil? && !name.nil? && META_NEW[name] == call.name
379
+ end
380
+
381
+ def self_receiver?(receiver)
382
+ receiver.nil? || receiver.is_a?(Prism::SelfNode) ||
383
+ (receiver.is_a?(Prism::CallNode) && receiver.name == :class && receiver.receiver.is_a?(Prism::SelfNode))
384
+ end
385
+
386
+ # `Object.const_get` is the top level, which every lookup already reaches.
387
+ def constant_receiver?(receiver)
388
+ return false unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
389
+
390
+ as_written = Source::ConstantPath.qualified_name_or_nil(receiver)
391
+ !as_written.nil? && as_written.delete_prefix("::") != "Object"
392
+ end
393
+
394
+ def receiver_reference(receiver, nesting)
395
+ Reference.new(as_written: Source::ConstantPath.qualified_name_or_nil(receiver),
396
+ nesting: nesting.dup.freeze, from: nil, role: @role, path: @path,
397
+ line: receiver.location.start_line, rooted: Source::ConstantPath.rooted?(receiver))
398
+ end
399
+
400
+ # `[prefix, scope, within]` for a literal head under one anchor. A head ending in `::` names a
401
+ # namespace and reaches everything beneath it; any other head is the start of a name, so
402
+ # `"V#{version}"` reaches `V1_0` and `V2_2` alike, and only a string-prefix match says that. A rooted
403
+ # head ignores the receiver. `within` carries a constant receiver for {Graph} to resolve.
404
+ def bound(head, anchor)
405
+ anchor = TOP_LEVEL if head.start_with?("::")
406
+ head = head.delete_prefix("::")
407
+ scope = head.end_with?("::") ? :namespace : :leading
408
+ head = head.delete_suffix("::")
409
+ return nil if head.empty?
410
+
411
+ anchor == TOP_LEVEL ? [head, scope, nil] : [head, scope, anchor]
412
+ end
413
+
414
+ # The values a dynamic-resolution subject can hold: the subject itself, or for a local variable every
415
+ # value assigned to it in the enclosing local scope. A local with an assignment whose value cannot be
416
+ # read off the node (`+=`, a multiple assignment, `rescue => name`) stays a computed value.
417
+ #
418
+ # A name an enclosing block binds as a parameter or block-local (`|name|`, `|x; name|`) is that
419
+ # block's value, not the method's: reading it through the method's assignments named a literal the
420
+ # block never sees, and a constant only that literal named was hidden from the report.
421
+ def subjects(subject)
422
+ subject = pass_through(subject)
423
+ return [subject] unless subject.is_a?(Prism::LocalVariableReadNode) && @local_scope
424
+ return [subject] if @block_params.include?(subject.name)
425
+
426
+ (LocalValues.values(@local_scope, subject.name) || [subject]).map { |value| pass_through(value) }
427
+ end
428
+
429
+ # The string or symbol under `"V#{v}".freeze` / `.to_sym`; any other node unchanged.
430
+ def pass_through(node)
431
+ while node.is_a?(Prism::CallNode) && PASS_THROUGH.include?(node.name) && node.arguments.nil? &&
432
+ node.block.nil? && SUBJECT_SHAPES.key?(pass_through(node.receiver).class)
433
+ node = node.receiver
434
+ end
435
+ node
436
+ end
437
+
438
+ # `[exact name, literal head]` for a dynamic-resolution subject. A literal names its constant exactly;
439
+ # an interpolation can only bound the names its literal head starts; anything else bounds nothing.
440
+ def dynamic_target(subject)
441
+ case subject
442
+ when Prism::StringNode, Prism::SymbolNode then [Scan.usable_name(subject.unescaped), nil]
443
+ when Prism::InterpolatedStringNode, Prism::InterpolatedSymbolNode then [nil, literal_head(subject)]
444
+ else [nil, nil]
445
+ end
446
+ end
447
+
448
+ # The literal head of an interpolated name, as written: `"Foo::Bar::#{k}"` gives `Foo::Bar::`. Returns
449
+ # nil when the interpolation starts the string, which bounds nothing.
450
+ def literal_head(node)
451
+ head = node.parts.first
452
+ return nil unless head.is_a?(Prism::StringNode)
453
+
454
+ literal = Scan.usable_name(head.unescaped)
455
+ literal.nil? || literal.empty? ? nil : literal
456
+ end
457
+ end
458
+
200
459
  # Single-pass walker. Tracks `nesting` as the stack of enclosing declaration names.
201
460
  class Walker
461
+ include DynamicSites
462
+
202
463
  attr_reader :declarations, :references, :dynamic_uses
203
464
 
204
465
  def initialize(path:, role:)
@@ -213,6 +474,19 @@ module Rigor
213
474
  @local_scope = nil
214
475
  # Names an enclosing block's parameters or block-locals shadow within @local_scope.
215
476
  @block_params = Set.new.freeze
477
+ # Whether `self` here is something other than the enclosing declaration — inside an `included do`,
478
+ # `class_eval` or anonymous `Class.new` block. See {#rebinds_self?}.
479
+ @self_rebound = false
480
+ @rebinding_blocks = Set.new.compare_by_identity
481
+ # What `self` is here, for {#self_lookup}: `:declaration` in a class or module body (and at the top
482
+ # level), `:singleton_method` in a `def self.x` or a def in a `class << self` body,
483
+ # `:instance_method` in any other def, `:singleton_class` in a `class << self` body outside its
484
+ # methods, and `:unknown` where the walk cannot name it.
485
+ @self_kind = :declaration
486
+ # Whether the walk is inside a block, whose `self` it cannot name. See {#self_lookup}.
487
+ @in_block = false
488
+ # Whether the innermost declaration is a module, whose instance methods' `self.class` is not it.
489
+ @module_body = false
216
490
  end
217
491
 
218
492
  def walk(node, nesting)
@@ -220,7 +494,7 @@ module Rigor
220
494
 
221
495
  case node
222
496
  when Prism::ClassNode then return walk_declaration(node, nesting, superclass: node.superclass)
223
- when Prism::ModuleNode then return walk_declaration(node, nesting, superclass: nil)
497
+ when Prism::ModuleNode then return walk_declaration(node, nesting, superclass: nil, module_body: true)
224
498
  when Prism::ConstantReadNode, Prism::ConstantPathNode
225
499
  record_reference(node, nesting)
226
500
  # A constant path's segments are not separate references — `A::B::C` is one reference to the leaf,
@@ -236,7 +510,7 @@ module Rigor
236
510
  walk_constant_write(node, nesting)
237
511
  return
238
512
  when Prism::CallNode
239
- record_dynamic_use(node, nesting)
513
+ record_call(node, nesting)
240
514
  when Prism::ProgramNode, Prism::DefNode, Prism::SingletonClassNode
241
515
  return in_local_scope(node) { node.rigor_each_child { |child| walk(child, nesting) } }
242
516
  when Prism::BlockNode, Prism::LambdaNode
@@ -248,7 +522,7 @@ module Rigor
248
522
 
249
523
  private
250
524
 
251
- def walk_declaration(node, nesting, superclass:)
525
+ def walk_declaration(node, nesting, superclass:, module_body: false)
252
526
  name = Source::ConstantPath.qualified_name(node.constant_path)
253
527
  return node.rigor_each_child { |child| walk(child, nesting) } if name.nil?
254
528
 
@@ -272,18 +546,15 @@ module Rigor
272
546
  # The body's nesting names the declaration itself, so a credit inherited from a meta-new rvalue
273
547
  # this declaration sits in must not leak into it.
274
548
  owner = @owner
549
+ saved = [@self_rebound, @in_block, @module_body]
275
550
  @owner = nil
276
- in_local_scope(node.body) { walk(node.body, nesting + [name]) }
551
+ @self_rebound = @in_block = false
552
+ @module_body = module_body
553
+ in_local_scope(node.body, :declaration) { walk(node.body, nesting + [name]) }
277
554
  @owner = owner
555
+ @self_rebound, @in_block, @module_body = saved
278
556
  end
279
557
 
280
- # `Const = Class.new` / `Module.new` / `Data.define(...)` / `Struct.new(...)` declare a class under a
281
- # constant, and `ScopeIndexer#record_class_new_constant_decl` already treats them as class declarations
282
- # for cross-file resolution. The report must agree, or every such constant reads as an unreferenced
283
- # value rather than a class.
284
- META_NEW = { "Class" => :new, "Module" => :new, "Data" => :define, "Struct" => :new }.freeze
285
- private_constant :META_NEW
286
-
287
558
  # A meta-new rvalue — `Class.new(Base) { ... }`, `Struct.new(:a) do ... end` — is the declaration's
288
559
  # header and body, so the references in it are credited to the declared constant, exactly as a
289
560
  # `class Sub < Base` superclass is (#1720). Its lexical nesting is unchanged: a block opens no cref.
@@ -330,173 +601,61 @@ module Rigor
330
601
  end
331
602
  end
332
603
 
333
- # Names that turn a String into a constant. `constantize` / `safe_constantize` are ActiveSupport;
334
- # `const_get` is core and may carry an explicit receiver (`Object.const_get`, `self.class.const_get`).
335
- DYNAMIC_RESOLVERS = %i[constantize safe_constantize const_get].freeze
336
- private_constant :DYNAMIC_RESOLVERS
337
-
338
- # The anchor of a lookup that starts at the top level.
339
- TOP_LEVEL = ""
340
- private_constant :TOP_LEVEL
341
-
342
- SUBJECT_SHAPES = {
343
- Prism::StringNode => "a literal string",
344
- Prism::SymbolNode => "a literal symbol",
345
- Prism::InterpolatedStringNode => "an interpolated string"
346
- }.freeze
347
- private_constant :SUBJECT_SHAPES
348
-
349
- # Rigor knows the argument's shape, which is the whole reason this can be tiered rather than treated
350
- # as a blanket namespace poison: a literal argument names the exact constant and is as good as a
351
- # written reference, while an interpolated one can only bound the names its literal head reaches.
352
- #
353
- # A local variable argument is read through its assignments in the same local scope, because the
354
- # name is usually built one line up: GitLab's `Migration[2.2]` is `name = "V#{...}"` followed by
355
- # `const_get(name, false)` (#1734).
356
- def record_dynamic_use(node, nesting)
357
- return unless DYNAMIC_RESOLVERS.include?(node.name)
358
-
359
- subject = node.name == :const_get ? node.arguments&.arguments&.first : node.receiver
360
- return if subject.nil?
361
-
362
- anchors = node.name == :const_get ? const_get_anchors(node, nesting) : [TOP_LEVEL]
363
- subjects(subject).each { |value| record_dynamic_subject(node, value, anchors) }
604
+ def in_local_scope(scope, self_kind = self_kind_in(scope))
605
+ saved = [@local_scope, @block_params, @self_kind]
606
+ @local_scope = scope
607
+ @block_params = Set.new.freeze
608
+ @self_kind = self_kind
609
+ yield
610
+ ensure
611
+ @local_scope, @block_params, @self_kind = saved
364
612
  end
365
613
 
366
- def record_dynamic_subject(node, subject, anchors)
367
- name, head = dynamic_target(subject)
368
- # A literal whose bytes are not valid UTF-8 cannot name a constant; dropping it is the only safe
369
- # answer, and carrying it forward crashed the whole run downstream.
370
- return if subject.is_a?(Prism::StringNode) && name.nil?
371
- return if subject.is_a?(Prism::SymbolNode) && name.nil?
372
-
373
- reason = "#{node.name} on #{SUBJECT_SHAPES.fetch(subject.class, 'a computed value')}"
374
- bounds = head ? anchors.filter_map { |anchor| bound(head, anchor) }.uniq : []
375
- bounds = [[nil, :namespace, nil]] if bounds.empty?
376
- bounds.each do |prefix, scope, within|
377
- @dynamic_uses << DynamicUse.new(name: name, prefix: prefix, scope: scope, within: within,
378
- site: site(node), path: @path, line: node.location.start_line,
379
- reason: reason)
614
+ # The {#self_lookup} kind inside `scope`, from the kind around it. Only a receiver-less def or a `def
615
+ # self.x` directly in a declaration body, and a `class << self` there, keep `self` nameable.
616
+ def self_kind_in(scope)
617
+ case scope
618
+ when Prism::ProgramNode then :declaration
619
+ when Prism::SingletonClassNode
620
+ scope.expression.is_a?(Prism::SelfNode) && @self_kind == :declaration ? :singleton_class : :unknown
621
+ when Prism::DefNode then def_self_kind(scope)
622
+ else :unknown
380
623
  end
381
624
  end
382
625
 
383
- # Where a `const_get` looks a name up, as the namespaces its interpolated argument is bounded by. The
384
- # receiver's namespace comes first: an implicit receiver, `self` or `self.class` is the enclosing
385
- # declaration, and a constant receiver is resolved by {Graph} as any reference is (`within`). With
386
- # the default `inherit = true` the lookup also falls through to the top level, which is all the
387
- # reading had before, so that stays tainted too; an explicit `false` confines it to the receiver. An
388
- # unknown receiver bounds nothing beyond the top level.
389
- def const_get_anchors(node, nesting)
390
- receiver = node.receiver
391
- namespaces =
392
- if self_receiver?(receiver)
393
- # Inside a meta-new rvalue `self` is the declared class in its block but the outer scope in its
394
- # arguments; the walk does not tell the two apart, so both are taken.
395
- [@owner, nesting.join("::")].compact.uniq - [TOP_LEVEL]
396
- elsif constant_receiver?(receiver)
397
- [receiver_reference(receiver, nesting)]
398
- else
399
- []
626
+ # A receiver-less def names its `self` only directly in a class body or a `class << self` body. In a
627
+ # module body `self.class` is the includer's class, or `Module` under `module_function` / `extend
628
+ # self`; a def nested in another def lands on whatever that method's cref is, which the walk cannot
629
+ # tell apart.
630
+ def def_self_kind(node)
631
+ if node.receiver.nil?
632
+ case @self_kind
633
+ when :singleton_class then :singleton_method
634
+ when :declaration then @module_body ? :unknown : :instance_method
635
+ else :unknown
400
636
  end
401
- return [TOP_LEVEL] if namespaces.empty?
402
-
403
- inherit = node.arguments.arguments[1]
404
- inherit.is_a?(Prism::FalseNode) ? namespaces : namespaces + [TOP_LEVEL]
405
- end
406
-
407
- def self_receiver?(receiver)
408
- receiver.nil? || receiver.is_a?(Prism::SelfNode) ||
409
- (receiver.is_a?(Prism::CallNode) && receiver.name == :class && receiver.receiver.is_a?(Prism::SelfNode))
410
- end
411
-
412
- # `Object.const_get` is the top level, which every lookup already reaches.
413
- def constant_receiver?(receiver)
414
- return false unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
415
-
416
- as_written = Source::ConstantPath.qualified_name_or_nil(receiver)
417
- !as_written.nil? && as_written.delete_prefix("::") != "Object"
418
- end
419
-
420
- def receiver_reference(receiver, nesting)
421
- Reference.new(as_written: Source::ConstantPath.qualified_name_or_nil(receiver),
422
- nesting: nesting.dup.freeze, from: nil, role: @role, path: @path,
423
- line: receiver.location.start_line, rooted: Source::ConstantPath.rooted?(receiver))
424
- end
425
-
426
- # `[prefix, scope, within]` for a literal head under one anchor. A head ending in `::` names a
427
- # namespace and reaches everything beneath it; any other head is the start of a name, so
428
- # `"V#{version}"` reaches `V1_0` and `V2_2` alike, and only a string-prefix match says that. A rooted
429
- # head ignores the receiver. `within` carries a constant receiver for {Graph} to resolve.
430
- def bound(head, anchor)
431
- anchor = TOP_LEVEL if head.start_with?("::")
432
- head = head.delete_prefix("::")
433
- scope = head.end_with?("::") ? :namespace : :leading
434
- head = head.delete_suffix("::")
435
- return nil if head.empty?
436
-
437
- case anchor
438
- when Reference then [head, scope, anchor]
439
- when TOP_LEVEL then [head, scope, nil]
440
- else ["#{anchor}::#{head}", scope, nil]
637
+ elsif node.receiver.is_a?(Prism::SelfNode) && @self_kind == :declaration
638
+ :singleton_method
639
+ else
640
+ :unknown
441
641
  end
442
642
  end
443
643
 
444
- # The values a dynamic-resolution subject can hold: the subject itself, or for a local variable every
445
- # value assigned to it in the enclosing local scope. A local with an assignment whose value cannot be
446
- # read off the node (`+=`, a multiple assignment, `rescue => name`) stays a computed value.
447
- #
448
- # A name an enclosing block binds as a parameter or block-local (`|name|`, `|x; name|`) is that
449
- # block's value, not the method's: reading it through the method's assignments named a literal the
450
- # block never sees, and a constant only that literal named was hidden from the report.
451
- def subjects(subject)
452
- return [subject] unless subject.is_a?(Prism::LocalVariableReadNode) && @local_scope
453
- return [subject] if @block_params.include?(subject.name)
454
-
455
- LocalValues.values(@local_scope, subject.name) || [subject]
456
- end
457
-
458
- def in_local_scope(scope)
459
- saved = [@local_scope, @block_params]
460
- @local_scope = scope
461
- @block_params = Set.new.freeze
462
- yield
463
- ensure
464
- @local_scope, @block_params = saved
465
- end
466
-
467
644
  def in_block(block)
468
- saved = @block_params
645
+ saved = [@block_params, @self_rebound, @in_block]
469
646
  names = block.parameters ? LocalValues.bindings(block.parameters) : []
470
647
  @block_params = (@block_params | names).freeze unless names.empty?
648
+ @self_rebound ||= @rebinding_blocks.include?(block)
649
+ @in_block = true
471
650
  yield
472
651
  ensure
473
- @block_params = saved
474
- end
475
-
476
- # `[exact name, literal head]` for a dynamic-resolution subject. A literal names its constant exactly;
477
- # an interpolation can only bound the names its literal head starts; anything else bounds nothing.
478
- def dynamic_target(subject)
479
- case subject
480
- when Prism::StringNode, Prism::SymbolNode then [Scan.usable_name(subject.unescaped), nil]
481
- when Prism::InterpolatedStringNode then [nil, literal_head(subject)]
482
- else [nil, nil]
483
- end
652
+ @block_params, @self_rebound, @in_block = saved
484
653
  end
485
654
 
486
655
  def site(node)
487
656
  "#{@path}:#{node.location.start_line}"
488
657
  end
489
658
 
490
- # The literal head of an interpolated name, as written: `"Foo::Bar::#{k}"` gives `Foo::Bar::`. Returns
491
- # nil when the interpolation starts the string, which bounds nothing.
492
- def literal_head(node)
493
- head = node.parts.first
494
- return nil unless head.is_a?(Prism::StringNode)
495
-
496
- literal = Scan.usable_name(head.unescaped)
497
- literal.nil? || literal.empty? ? nil : literal
498
- end
499
-
500
659
  # `from` defaults to the innermost enclosing declaration; a declaration's own header (its superclass,
501
660
  # its meta-new rvalue) is credited to that declaration instead.
502
661
  def record_reference(node, nesting, from: @owner)
@@ -48,11 +48,13 @@ module Rigor
48
48
  # @param cache_path — the configuration's cache root; nil or empty is an inert
49
49
  # cache (every fetch computes, nothing persists).
50
50
  # @param target_ruby — parse-affecting configuration, part of the identity.
51
- def self.open(cache_path, target_ruby: nil)
51
+ # `root` names the project and working directory a run reads roles and spellings from (#1760): a
52
+ # scan cached by a run from another directory carries another role, so it is never served here.
53
+ def self.open(cache_path, target_ruby: nil, root: nil)
52
54
  return new(path: nil, header: nil) if cache_path.nil? || cache_path.to_s.empty?
53
55
 
54
56
  new(path: File.join(cache_path.to_s, FILE_NAME),
55
- header: "#{SCHEMA}:#{Rigor::VERSION}:#{target_ruby}:#{source_digest}")
57
+ header: "#{SCHEMA}:#{Rigor::VERSION}:#{target_ruby}:#{source_digest}:#{root}")
56
58
  end
57
59
 
58
60
  def self.source_digest
@@ -64,7 +64,7 @@ module Rigor
64
64
  # the caller loads the engine and runs the full path. Any failure declines rather than raising: the
65
65
  # probe must never turn a servable run into a crash.
66
66
  def serve(paths)
67
- files = PathExpansion.ruby_files(paths, @configuration.exclude_patterns)
67
+ files = PathExpansion.ruby_files(paths, @configuration.exclude_patterns, base: @configuration.exclude_base)
68
68
  key = RunCacheKey.descriptor(
69
69
  configuration: @configuration, files: files, explain: @explain,
70
70
  rbs_config_entries: RunCacheKey.libraries_config_entries(@configuration)