rigortype 0.4.2 → 0.4.4

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +28 -5
  5. data/docs/manual/02-cli-reference.md +23 -4
  6. data/docs/manual/03-configuration.md +21 -8
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/05-inspecting-types.md +50 -0
  9. data/docs/manual/07-plugins.md +1 -1
  10. data/docs/manual/10-mcp-server.md +32 -3
  11. data/docs/manual/12-caching.md +18 -1
  12. data/docs/manual/14-rails-quickstart.md +2 -2
  13. data/docs/manual/18-removing-dead-code.md +26 -9
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  16. data/exe/rigor +12 -17
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
  18. data/lib/rigor/analysis/check_rules.rb +68 -57
  19. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  20. data/lib/rigor/analysis/incremental.rb +12 -0
  21. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  22. data/lib/rigor/analysis/incremental_session.rb +207 -40
  23. data/lib/rigor/analysis/path_expansion.rb +62 -6
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  25. data/lib/rigor/analysis/reachability/graph.rb +139 -15
  26. data/lib/rigor/analysis/reachability/scan.rb +374 -173
  27. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  28. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  29. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  30. data/lib/rigor/analysis/run_cache_probe.rb +6 -4
  31. data/lib/rigor/analysis/run_stats.rb +28 -6
  32. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
  33. data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
  34. data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
  35. data/lib/rigor/analysis/runner.rb +62 -18
  36. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  37. data/lib/rigor/analysis/worker_session.rb +58 -2
  38. data/lib/rigor/cache/descriptor.rb +40 -6
  39. data/lib/rigor/cache/engine_source.rb +21 -7
  40. data/lib/rigor/cache/incremental_snapshot.rb +56 -12
  41. data/lib/rigor/cli/check_command.rb +138 -39
  42. data/lib/rigor/cli/docs_command.rb +5 -4
  43. data/lib/rigor/cli/lens_command.rb +144 -0
  44. data/lib/rigor/cli/lsp_command.rb +5 -2
  45. data/lib/rigor/cli/mcp_command.rb +1 -1
  46. data/lib/rigor/cli/skill_command.rb +3 -3
  47. data/lib/rigor/cli/skill_describe.rb +1 -1
  48. data/lib/rigor/cli/type_of_command.rb +2 -1
  49. data/lib/rigor/cli/unused_command.rb +157 -24
  50. data/lib/rigor/cli.rb +30 -4
  51. data/lib/rigor/config_audit.rb +111 -15
  52. data/lib/rigor/configuration/dependencies.rb +19 -11
  53. data/lib/rigor/configuration.rb +15 -0
  54. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  55. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  56. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  58. data/lib/rigor/environment/rbs_loader.rb +79 -10
  59. data/lib/rigor/environment/required_features.rb +4 -2
  60. data/lib/rigor/environment.rb +12 -5
  61. data/lib/rigor/inference/block_call_timing.rb +5 -0
  62. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  63. data/lib/rigor/inference/captured_locals.rb +69 -6
  64. data/lib/rigor/inference/definer_resolution.rb +16 -7
  65. data/lib/rigor/inference/expression_typer.rb +117 -29
  66. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  67. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  68. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  69. data/lib/rigor/inference/match_rebinding.rb +1 -1
  70. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  71. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  72. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  73. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  74. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  75. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  76. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  77. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  78. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  79. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  80. data/lib/rigor/inference/module_function_state.rb +80 -13
  81. data/lib/rigor/inference/narrowing.rb +184 -68
  82. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  83. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  84. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  85. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  86. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  87. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  88. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  89. data/lib/rigor/inference/repeated_or_writes.rb +416 -1
  90. data/lib/rigor/inference/scope_indexer.rb +565 -61
  91. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  92. data/lib/rigor/inference/statement_evaluator.rb +307 -60
  93. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  94. data/lib/rigor/language_server/project_context.rb +56 -5
  95. data/lib/rigor/lens/anchor.rb +115 -0
  96. data/lib/rigor/lens/contract_reader.rb +133 -0
  97. data/lib/rigor/lens/document.rb +113 -0
  98. data/lib/rigor/lens/file_reader.rb +802 -0
  99. data/lib/rigor/lens/members.rb +203 -0
  100. data/lib/rigor/lens/parameters.rb +91 -0
  101. data/lib/rigor/lens/plugins.rb +40 -0
  102. data/lib/rigor/lens/run.rb +322 -0
  103. data/lib/rigor/lens/slot.rb +79 -0
  104. data/lib/rigor/lens.rb +41 -0
  105. data/lib/rigor/mcp/server.rb +61 -20
  106. data/lib/rigor/plugin/box_probe.rb +154 -7
  107. data/lib/rigor/project_environment.rb +23 -1
  108. data/lib/rigor/protection/mutation_cache.rb +2 -1
  109. data/lib/rigor/scope.rb +33 -17
  110. data/lib/rigor/sig_gen/generator.rb +45 -5
  111. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  112. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  113. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  114. data/lib/rigor/sig_gen/writer.rb +47 -34
  115. data/lib/rigor/version.rb +1 -1
  116. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  117. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  118. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  119. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  120. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  121. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  122. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  125. data/sig/rigor/scope.rbs +3 -0
  126. data/sig/rigor.rbs +6 -3
  127. data/skills/rigor-project-init/references/02-configure.md +2 -2
  128. metadata +21 -1
@@ -51,10 +51,22 @@ 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
+ #
61
+ # `receiver_open` is true when `within` is the enclosing declaration standing for a runtime `self` that
62
+ # may be one of its descendants (#1878): a `def self.x = const_get(:X)` runs on whichever subclass it
63
+ # is called on, and a module's method on whichever class includes, prepends or extends it. {Graph}
64
+ # then looks the literal name up in those descendants as well.
65
+ DynamicUse = Data.define(:name, :prefix, :reason, :site, :path, :line, :scope, :within, :inherit, :role,
66
+ :receiver_open) do
67
+ def initialize(scope: :namespace, within: nil, inherit: true, role: :production, receiver_open: false, **)
68
+ super
69
+ end
58
70
 
59
71
  # Whether this site's evidence reaches `fqn`.
60
72
  def taints?(fqn)
@@ -65,18 +77,28 @@ module Rigor
65
77
  fqn == prefix || fqn.start_with?("#{prefix}::")
66
78
  end
67
79
 
68
- # This site with `within` resolved by the block into absolute `prefix`es. A resolution of the whole
80
+ # The namespaces an interpolated site's `within` names, resolved by the block. A resolution of the whole
69
81
  # 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?
82
+ # anchoring there alone looked for `Foo::V*` and missed `Foo::Bar::V1`; the written name is then an
83
+ # anchor as well as the peeled one, since an undeclared receiver may be an alias of either. A receiver
84
+ # that does not resolve at all keeps its written name. Over-reaching is safe for a taint only; a
85
+ # literal anchors at {#receiver} alone.
86
+ def anchors
87
+ resolved = yield(within)
88
+ whole?(resolved) ? [resolved] : [within.as_written, resolved].compact
89
+ end
75
90
 
76
- written = within.as_written
91
+ # The declaration `within` names when it resolves as WHOLE, or nil. A receiver resolved only by peeling
92
+ # its last segment is unknown: `const_get` never searches the receiver's lexical parent, so anchoring a
93
+ # literal there could resolve it to a dead constant and miss the live one.
94
+ def receiver
77
95
  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) }
96
+ whole?(resolved) ? resolved : nil
97
+ end
98
+
99
+ def whole?(resolved)
100
+ written = within.as_written
101
+ resolved == written || resolved&.end_with?("::#{written}")
80
102
  end
81
103
  end
82
104
 
@@ -128,7 +150,8 @@ module Rigor
128
150
  # @param root — the project root `path` is made relative to when its role is decided.
129
151
  # @return nil when the file does not parse (a parse error is the analyzer's business, not
130
152
  # this scan's — it simply contributes nothing rather than half a file).
131
- def self.call(path:, source:, target_ruby: nil, root: nil)
153
+ # `role`, when given, is the referring file's role, decided by the caller instead of from `path` (#1760).
154
+ def self.call(path:, source:, target_ruby: nil, root: nil, role: nil)
132
155
  parsed = if target_ruby
133
156
  Prism.parse(source, filepath: path,
134
157
  version: target_ruby)
@@ -137,7 +160,7 @@ module Rigor
137
160
  end
138
161
  return nil unless parsed.success?
139
162
 
140
- walker = Walker.new(path: path, role: role_for(path, root: root))
163
+ walker = Walker.new(path: path, role: role || role_for(path, root: root))
141
164
  walker.walk(parsed.value, [])
142
165
  Result.new(declarations: walker.declarations.freeze, references: walker.references.freeze,
143
166
  dynamic_uses: walker.dynamic_uses.freeze)
@@ -197,8 +220,280 @@ module Rigor
197
220
  end
198
221
  end
199
222
 
223
+ # `Const = Class.new` / `Module.new` / `Data.define(...)` / `Struct.new(...)` declare a class under a
224
+ # constant, and `ScopeIndexer#record_class_new_constant_decl` already treats them as class declarations
225
+ # for cross-file resolution. The report must agree, or every such constant reads as an unreferenced
226
+ # value rather than a class.
227
+ META_NEW = { "Class" => :new, "Module" => :new, "Data" => :define, "Struct" => :new }.freeze
228
+ private_constant :META_NEW
229
+
230
+ # ADR-102 WD4 — the {Walker}'s reading of dynamic constant resolution: `constantize` and `const_get`
231
+ # sites, the receivers a `const_get` looks its name up from, and the values its argument can hold.
232
+ module DynamicSites
233
+ private
234
+
235
+ # Names that turn a String into a constant. `constantize` / `safe_constantize` are ActiveSupport;
236
+ # `const_get` is core and may carry an explicit receiver (`Object.const_get`, `self.class.const_get`).
237
+ DYNAMIC_RESOLVERS = %i[constantize safe_constantize const_get].freeze
238
+ private_constant :DYNAMIC_RESOLVERS
239
+
240
+ # The anchor of a lookup that starts at the top level.
241
+ TOP_LEVEL = ""
242
+ private_constant :TOP_LEVEL
243
+
244
+ SUBJECT_SHAPES = {
245
+ Prism::StringNode => "a literal string",
246
+ Prism::SymbolNode => "a literal symbol",
247
+ Prism::InterpolatedStringNode => "an interpolated string",
248
+ Prism::InterpolatedSymbolNode => "an interpolated symbol"
249
+ }.freeze
250
+ private_constant :SUBJECT_SHAPES
251
+
252
+ # Calls that hand a string or symbol through unchanged, as far as the name it spells: `"V#{v}".freeze`.
253
+ PASS_THROUGH = %i[freeze to_s to_str to_sym dup -@ +@].freeze
254
+ private_constant :PASS_THROUGH
255
+
256
+ # Blocks whose `self` is not the enclosing declaration: a concern's `included` / `class_methods` body
257
+ # runs on the including class, and an eval block on its receiver.
258
+ SELF_REBINDING = %i[included prepended extended class_methods class_eval module_eval class_exec
259
+ module_exec instance_eval instance_exec].freeze
260
+ private_constant :SELF_REBINDING
261
+
262
+ # The `*_eval` / `*_exec` family: on a constant receiver, the block's `self` IS that constant (#1878).
263
+ EVAL_FAMILY = %i[class_eval module_eval class_exec module_exec instance_eval instance_exec].freeze
264
+ private_constant :EVAL_FAMILY
265
+
266
+ # A call is a dynamic-resolution site, and its block may run with another `self` ({#rebinds_self?}).
267
+ def record_call(node, nesting)
268
+ record_dynamic_use(node, nesting)
269
+ return unless node.block.is_a?(Prism::BlockNode) && rebinds_self?(node)
270
+
271
+ @rebinding_blocks << node.block
272
+ @eval_receivers[node.block] = receiver_reference(node.receiver, nesting) if eval_on_constant?(node)
273
+ end
274
+
275
+ def eval_on_constant?(call)
276
+ EVAL_FAMILY.include?(call.name) && constant_receiver?(call.receiver)
277
+ end
278
+
279
+ # Rigor knows the argument's shape, which is the whole reason this can be tiered rather than treated
280
+ # as a blanket namespace poison: a literal argument names the exact constant and is as good as a
281
+ # written reference, while an interpolated one can only bound the names its literal head reaches.
282
+ #
283
+ # A local variable argument is read through its assignments in the same local scope, because the
284
+ # name is usually built one line up: GitLab's `Migration[2.2]` is `name = "V#{...}"` followed by
285
+ # `const_get(name, false)` (#1734).
286
+ def record_dynamic_use(node, nesting)
287
+ return unless DYNAMIC_RESOLVERS.include?(node.name)
288
+
289
+ subject = node.name == :const_get ? node.arguments&.arguments&.first : node.receiver
290
+ return if subject.nil?
291
+
292
+ anchors = node.name == :const_get ? const_get_anchors(node, nesting) : [TOP_LEVEL]
293
+ subjects(subject).each { |value| record_dynamic_subject(node, value, anchors) }
294
+ end
295
+
296
+ def record_dynamic_subject(node, subject, anchors)
297
+ name, head = dynamic_target(subject)
298
+ # A literal whose bytes are not valid UTF-8 cannot name a constant; dropping it is the only safe
299
+ # answer, and carrying it forward crashed the whole run downstream.
300
+ return if subject.is_a?(Prism::StringNode) && name.nil?
301
+ return if subject.is_a?(Prism::SymbolNode) && name.nil?
302
+
303
+ reason = "#{node.name} on #{SUBJECT_SHAPES.fetch(subject.class, 'a computed value')}"
304
+ bounds = if name then literal_bounds(node, name, anchors)
305
+ elsif head then anchors.filter_map { |anchor| bound(head, anchor) }.uniq
306
+ else []
307
+ end
308
+ bounds = [[nil, :namespace, nil]] if bounds.empty?
309
+ bounds.each do |prefix, scope, within|
310
+ @dynamic_uses << DynamicUse.new(name: name, prefix: prefix, scope: scope, within: within,
311
+ inherit: anchors.include?(TOP_LEVEL), role: @role,
312
+ receiver_open: !within.nil? && runtime_self?(node),
313
+ site: site(node), path: @path, line: node.location.start_line,
314
+ reason: reason)
315
+ end
316
+ end
317
+
318
+ # A literal name is looked up from each receiver the call can have, so it carries the receiver as
319
+ # `within` and {Graph} resolves it there first (#1761). A rooted name, or a call whose only anchor is
320
+ # the top level, is looked up from the top level as any reference is.
321
+ #
322
+ # On `self` the receiver is the enclosing declaration only where the walk can name it ({#self_lookup}).
323
+ # Where `self` is a `Class` or a singleton class, Ruby looks the name up at the top level. Where the
324
+ # walk cannot tell what `self` is, the name is read BOTH at the enclosing declaration and at the top
325
+ # level: either reading alone lists a live constant as a candidate when it is the wrong one, and a
326
+ # dead constant kept reachable is the cheaper mistake. An interpolated name keeps every anchor, since
327
+ # a taint is safe to over-reach.
328
+ def literal_bounds(node, name, anchors)
329
+ return [] if name.start_with?("::")
330
+
331
+ within = anchors.grep(Reference).map { |receiver| [nil, :namespace, receiver] }
332
+ return within unless self_receiver?(node.receiver)
333
+
334
+ case self_lookup(node.receiver)
335
+ when :declaration then within
336
+ when :top_level then []
337
+ else within + [[nil, :namespace, nil]]
338
+ end
339
+ end
340
+
341
+ # Whether a `const_get` on `self` can run on a descendant of the enclosing declaration (#1878): in a
342
+ # method, or a block that rebinds `self`, `self` is whichever subclass or includer the method was
343
+ # called on. Directly in a class body, or inside `Other.class_eval { }`, it is the declaration itself.
344
+ def runtime_self?(node)
345
+ node.name == :const_get && self_receiver?(node.receiver) && @eval_receiver.nil? &&
346
+ !(@self_kind == :declaration && !@in_block && !@self_rebound)
347
+ end
348
+
349
+ # Where a `const_get` on `self` (implicit, `self`, or `self.class`) looks its name up: `:declaration`
350
+ # when `self` is the enclosing class or module, `:top_level` when it is `Class` or a singleton class,
351
+ # `:unknown` otherwise. A block's `self` is unknown — `included do`, `class_eval` and an instance-
352
+ # evaluated DSL block (`configure { }`, an RSpec `describe` body) all rebind it, and the walk cannot
353
+ # tell those from an `each` block. So is the `self` of a `def Other.x` or of a `class << Other` body.
354
+ def self_lookup(receiver)
355
+ return :declaration if @eval_receiver && !receiver.is_a?(Prism::CallNode)
356
+ return :unknown if @in_block || @self_rebound
357
+
358
+ class_of_self = receiver.is_a?(Prism::CallNode)
359
+ case @self_kind
360
+ when :declaration, :singleton_method then class_of_self ? :top_level : :declaration
361
+ when :instance_method then class_of_self ? :declaration : :unknown
362
+ when :singleton_class then :top_level
363
+ else :unknown
364
+ end
365
+ end
366
+
367
+ # Where a `const_get` looks a name up, as the namespaces its interpolated argument is bounded by. The
368
+ # receiver's namespace comes first: an implicit receiver, `self` or `self.class` is the enclosing
369
+ # declaration, and a constant receiver is resolved by {Graph} as any reference is (`within`). With
370
+ # the default `inherit = true` the lookup also falls through to the top level, which is all the
371
+ # reading had before, so that stays tainted too; an explicit `false` confines it to the receiver. An
372
+ # unknown receiver bounds nothing beyond the top level.
373
+ def const_get_anchors(node, nesting)
374
+ receiver = node.receiver
375
+ namespaces =
376
+ if self_receiver?(receiver)
377
+ eval_self_references(receiver) || self_references(node, nesting)
378
+ elsif constant_receiver?(receiver)
379
+ [receiver_reference(receiver, nesting)]
380
+ else
381
+ []
382
+ end
383
+ return [TOP_LEVEL] if namespaces.empty?
384
+
385
+ inherit = node.arguments.arguments[1]
386
+ inherit.is_a?(Prism::FalseNode) ? namespaces : namespaces + [TOP_LEVEL]
387
+ end
388
+
389
+ # Inside `Other.class_eval { }` an implicit or `self` receiver is `Other` (#1878).
390
+ def eval_self_references(receiver)
391
+ [@eval_receiver] if @eval_receiver && !receiver.is_a?(Prism::CallNode)
392
+ end
393
+
394
+ # The enclosing declaration as a rooted {Reference}, so {Graph} anchors it as it anchors a constant
395
+ # receiver and can search its ancestors. Inside a meta-new rvalue `self` is the declared class in its
396
+ # block but the outer scope in its arguments; the walk does not tell the two apart, so both are taken.
397
+ def self_references(node, nesting)
398
+ ([@owner, nesting.join("::")].compact.uniq - [TOP_LEVEL]).map do |fqn|
399
+ Reference.new(as_written: fqn, nesting: [].freeze, from: nil, role: @role, path: @path,
400
+ line: node.location.start_line, rooted: true)
401
+ end
402
+ end
403
+
404
+ # Whether `call`'s block runs with a `self` other than the enclosing declaration's. A meta-new block
405
+ # assigned to a constant is that constant ({#walk_constant_write} credits it as `@owner`); an
406
+ # anonymous one is not the enclosing declaration either.
407
+ def rebinds_self?(call)
408
+ return true if SELF_REBINDING.include?(call.name)
409
+
410
+ recv = call.receiver
411
+ name = recv && Source::ConstantPath.qualified_name_or_nil(recv)&.delete_prefix("::")
412
+ @owner.nil? && !name.nil? && META_NEW[name] == call.name
413
+ end
414
+
415
+ def self_receiver?(receiver)
416
+ receiver.nil? || receiver.is_a?(Prism::SelfNode) ||
417
+ (receiver.is_a?(Prism::CallNode) && receiver.name == :class && receiver.receiver.is_a?(Prism::SelfNode))
418
+ end
419
+
420
+ # `Object.const_get` is the top level, which every lookup already reaches.
421
+ def constant_receiver?(receiver)
422
+ return false unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
423
+
424
+ as_written = Source::ConstantPath.qualified_name_or_nil(receiver)
425
+ !as_written.nil? && as_written.delete_prefix("::") != "Object"
426
+ end
427
+
428
+ def receiver_reference(receiver, nesting)
429
+ Reference.new(as_written: Source::ConstantPath.qualified_name_or_nil(receiver),
430
+ nesting: nesting.dup.freeze, from: nil, role: @role, path: @path,
431
+ line: receiver.location.start_line, rooted: Source::ConstantPath.rooted?(receiver))
432
+ end
433
+
434
+ # `[prefix, scope, within]` for a literal head under one anchor. A head ending in `::` names a
435
+ # namespace and reaches everything beneath it; any other head is the start of a name, so
436
+ # `"V#{version}"` reaches `V1_0` and `V2_2` alike, and only a string-prefix match says that. A rooted
437
+ # head ignores the receiver. `within` carries a constant receiver for {Graph} to resolve.
438
+ def bound(head, anchor)
439
+ anchor = TOP_LEVEL if head.start_with?("::")
440
+ head = head.delete_prefix("::")
441
+ scope = head.end_with?("::") ? :namespace : :leading
442
+ head = head.delete_suffix("::")
443
+ return nil if head.empty?
444
+
445
+ anchor == TOP_LEVEL ? [head, scope, nil] : [head, scope, anchor]
446
+ end
447
+
448
+ # The values a dynamic-resolution subject can hold: the subject itself, or for a local variable every
449
+ # value assigned to it in the enclosing local scope. A local with an assignment whose value cannot be
450
+ # read off the node (`+=`, a multiple assignment, `rescue => name`) stays a computed value.
451
+ #
452
+ # A name an enclosing block binds as a parameter or block-local (`|name|`, `|x; name|`) is that
453
+ # block's value, not the method's: reading it through the method's assignments named a literal the
454
+ # block never sees, and a constant only that literal named was hidden from the report.
455
+ def subjects(subject)
456
+ subject = pass_through(subject)
457
+ return [subject] unless subject.is_a?(Prism::LocalVariableReadNode) && @local_scope
458
+ return [subject] if @block_params.include?(subject.name)
459
+
460
+ (LocalValues.values(@local_scope, subject.name) || [subject]).map { |value| pass_through(value) }
461
+ end
462
+
463
+ # The string or symbol under `"V#{v}".freeze` / `.to_sym`; any other node unchanged.
464
+ def pass_through(node)
465
+ while node.is_a?(Prism::CallNode) && PASS_THROUGH.include?(node.name) && node.arguments.nil? &&
466
+ node.block.nil? && SUBJECT_SHAPES.key?(pass_through(node.receiver).class)
467
+ node = node.receiver
468
+ end
469
+ node
470
+ end
471
+
472
+ # `[exact name, literal head]` for a dynamic-resolution subject. A literal names its constant exactly;
473
+ # an interpolation can only bound the names its literal head starts; anything else bounds nothing.
474
+ def dynamic_target(subject)
475
+ case subject
476
+ when Prism::StringNode, Prism::SymbolNode then [Scan.usable_name(subject.unescaped), nil]
477
+ when Prism::InterpolatedStringNode, Prism::InterpolatedSymbolNode then [nil, literal_head(subject)]
478
+ else [nil, nil]
479
+ end
480
+ end
481
+
482
+ # The literal head of an interpolated name, as written: `"Foo::Bar::#{k}"` gives `Foo::Bar::`. Returns
483
+ # nil when the interpolation starts the string, which bounds nothing.
484
+ def literal_head(node)
485
+ head = node.parts.first
486
+ return nil unless head.is_a?(Prism::StringNode)
487
+
488
+ literal = Scan.usable_name(head.unescaped)
489
+ literal.nil? || literal.empty? ? nil : literal
490
+ end
491
+ end
492
+
200
493
  # Single-pass walker. Tracks `nesting` as the stack of enclosing declaration names.
201
494
  class Walker
495
+ include DynamicSites
496
+
202
497
  attr_reader :declarations, :references, :dynamic_uses
203
498
 
204
499
  def initialize(path:, role:)
@@ -213,6 +508,23 @@ module Rigor
213
508
  @local_scope = nil
214
509
  # Names an enclosing block's parameters or block-locals shadow within @local_scope.
215
510
  @block_params = Set.new.freeze
511
+ # Whether `self` here is something other than the enclosing declaration — inside an `included do`,
512
+ # `class_eval` or anonymous `Class.new` block. See {#rebinds_self?}.
513
+ @self_rebound = false
514
+ @rebinding_blocks = Set.new.compare_by_identity
515
+ # The constant receiver of each `*_eval` block, and the one the walk is inside (nil when `self` is not
516
+ # such a constant). See {#eval_on_constant?}.
517
+ @eval_receivers = {}.compare_by_identity
518
+ @eval_receiver = nil
519
+ # What `self` is here, for {#self_lookup}: `:declaration` in a class or module body (and at the top
520
+ # level), `:singleton_method` in a `def self.x` or a def in a `class << self` body,
521
+ # `:instance_method` in any other def, `:singleton_class` in a `class << self` body outside its
522
+ # methods, and `:unknown` where the walk cannot name it.
523
+ @self_kind = :declaration
524
+ # Whether the walk is inside a block, whose `self` it cannot name. See {#self_lookup}.
525
+ @in_block = false
526
+ # Whether the innermost declaration is a module, whose instance methods' `self.class` is not it.
527
+ @module_body = false
216
528
  end
217
529
 
218
530
  def walk(node, nesting)
@@ -220,7 +532,7 @@ module Rigor
220
532
 
221
533
  case node
222
534
  when Prism::ClassNode then return walk_declaration(node, nesting, superclass: node.superclass)
223
- when Prism::ModuleNode then return walk_declaration(node, nesting, superclass: nil)
535
+ when Prism::ModuleNode then return walk_declaration(node, nesting, superclass: nil, module_body: true)
224
536
  when Prism::ConstantReadNode, Prism::ConstantPathNode
225
537
  record_reference(node, nesting)
226
538
  # A constant path's segments are not separate references — `A::B::C` is one reference to the leaf,
@@ -236,7 +548,7 @@ module Rigor
236
548
  walk_constant_write(node, nesting)
237
549
  return
238
550
  when Prism::CallNode
239
- record_dynamic_use(node, nesting)
551
+ record_call(node, nesting)
240
552
  when Prism::ProgramNode, Prism::DefNode, Prism::SingletonClassNode
241
553
  return in_local_scope(node) { node.rigor_each_child { |child| walk(child, nesting) } }
242
554
  when Prism::BlockNode, Prism::LambdaNode
@@ -248,7 +560,7 @@ module Rigor
248
560
 
249
561
  private
250
562
 
251
- def walk_declaration(node, nesting, superclass:)
563
+ def walk_declaration(node, nesting, superclass:, module_body: false)
252
564
  name = Source::ConstantPath.qualified_name(node.constant_path)
253
565
  return node.rigor_each_child { |child| walk(child, nesting) } if name.nil?
254
566
 
@@ -272,18 +584,15 @@ module Rigor
272
584
  # The body's nesting names the declaration itself, so a credit inherited from a meta-new rvalue
273
585
  # this declaration sits in must not leak into it.
274
586
  owner = @owner
587
+ saved = [@self_rebound, @in_block, @module_body]
275
588
  @owner = nil
276
- in_local_scope(node.body) { walk(node.body, nesting + [name]) }
589
+ @self_rebound = @in_block = false
590
+ @module_body = module_body
591
+ in_local_scope(node.body, :declaration) { walk(node.body, nesting + [name]) }
277
592
  @owner = owner
593
+ @self_rebound, @in_block, @module_body = saved
278
594
  end
279
595
 
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
596
  # A meta-new rvalue — `Class.new(Base) { ... }`, `Struct.new(:a) do ... end` — is the declaration's
288
597
  # header and body, so the references in it are credited to the declared constant, exactly as a
289
598
  # `class Sub < Base` superclass is (#1720). Its lexical nesting is unchanged: a block opens no cref.
@@ -330,173 +639,65 @@ module Rigor
330
639
  end
331
640
  end
332
641
 
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) }
642
+ def in_local_scope(scope, self_kind = self_kind_in(scope))
643
+ saved = [@local_scope, @block_params, @self_kind, @eval_receiver]
644
+ @eval_receiver = nil
645
+ @local_scope = scope
646
+ @block_params = Set.new.freeze
647
+ @self_kind = self_kind
648
+ yield
649
+ ensure
650
+ @local_scope, @block_params, @self_kind, @eval_receiver = saved
364
651
  end
365
652
 
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)
653
+ # The {#self_lookup} kind inside `scope`, from the kind around it. Only a receiver-less def or a `def
654
+ # self.x` directly in a declaration body, and a `class << self` there, keep `self` nameable.
655
+ def self_kind_in(scope)
656
+ case scope
657
+ when Prism::ProgramNode then :declaration
658
+ when Prism::SingletonClassNode
659
+ scope.expression.is_a?(Prism::SelfNode) && @self_kind == :declaration ? :singleton_class : :unknown
660
+ when Prism::DefNode then def_self_kind(scope)
661
+ else :unknown
380
662
  end
381
663
  end
382
664
 
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
- []
665
+ # A receiver-less def names its `self` only directly in a class body or a `class << self` body. In a
666
+ # module body `self.class` is the includer's class, or `Module` under `module_function` / `extend
667
+ # self`; a def nested in another def lands on whatever that method's cref is, which the walk cannot
668
+ # tell apart.
669
+ def def_self_kind(node)
670
+ if node.receiver.nil?
671
+ case @self_kind
672
+ when :singleton_class then :singleton_method
673
+ when :declaration then @module_body ? :unknown : :instance_method
674
+ else :unknown
400
675
  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]
676
+ elsif node.receiver.is_a?(Prism::SelfNode) && @self_kind == :declaration
677
+ :singleton_method
678
+ else
679
+ :unknown
441
680
  end
442
681
  end
443
682
 
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
683
  def in_block(block)
468
- saved = @block_params
684
+ saved = [@block_params, @self_rebound, @in_block, @eval_receiver]
469
685
  names = block.parameters ? LocalValues.bindings(block.parameters) : []
470
686
  @block_params = (@block_params | names).freeze unless names.empty?
687
+ if @rebinding_blocks.include?(block)
688
+ @self_rebound = true
689
+ @eval_receiver = @eval_receivers[block]
690
+ end
691
+ @in_block = true
471
692
  yield
472
693
  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
694
+ @block_params, @self_rebound, @in_block, @eval_receiver = saved
484
695
  end
485
696
 
486
697
  def site(node)
487
698
  "#{@path}:#{node.location.start_line}"
488
699
  end
489
700
 
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
701
  # `from` defaults to the innermost enclosing declaration; a declaration's own header (its superclass,
501
702
  # its meta-new rvalue) is credited to that declaration instead.
502
703
  def record_reference(node, nesting, from: @owner)