rigortype 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/docs/handbook/11-sig-gen.md +12 -5
- data/docs/manual/02-cli-reference.md +19 -1
- data/docs/manual/03-configuration.md +6 -4
- data/docs/manual/05-inspecting-types.md +50 -0
- data/docs/manual/12-caching.md +18 -1
- data/docs/manual/14-rails-quickstart.md +2 -2
- data/docs/manual/18-removing-dead-code.md +16 -9
- data/lib/rigor/analysis/check_rules/rule_ids.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +23 -22
- data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
- data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
- data/lib/rigor/analysis/incremental_session.rb +89 -22
- data/lib/rigor/analysis/path_expansion.rb +62 -6
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
- data/lib/rigor/analysis/reachability/graph.rb +119 -15
- data/lib/rigor/analysis/reachability/scan.rb +332 -173
- data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
- data/lib/rigor/analysis/run_cache_probe.rb +1 -1
- data/lib/rigor/analysis/run_stats.rb +28 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +26 -1
- data/lib/rigor/analysis/runner/pool_coordinator.rb +28 -9
- data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
- data/lib/rigor/analysis/runner.rb +29 -11
- data/lib/rigor/analysis/worker_session.rb +1 -1
- data/lib/rigor/cache/engine_source.rb +21 -7
- data/lib/rigor/cache/incremental_snapshot.rb +36 -8
- data/lib/rigor/cli/check_command.rb +138 -39
- data/lib/rigor/cli/docs_command.rb +5 -4
- data/lib/rigor/cli/lens_command.rb +144 -0
- data/lib/rigor/cli/lsp_command.rb +5 -2
- data/lib/rigor/cli/skill_command.rb +3 -3
- data/lib/rigor/cli/skill_describe.rb +1 -1
- data/lib/rigor/cli/type_of_command.rb +2 -1
- data/lib/rigor/cli/unused_command.rb +157 -24
- data/lib/rigor/cli.rb +30 -4
- data/lib/rigor/configuration.rb +15 -0
- data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
- data/lib/rigor/environment/installed_gem_set.rb +38 -11
- data/lib/rigor/environment/lockfile_resolver.rb +37 -6
- data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
- data/lib/rigor/environment/rbs_loader.rb +33 -0
- data/lib/rigor/environment/required_features.rb +4 -2
- data/lib/rigor/environment.rb +12 -5
- data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
- data/lib/rigor/inference/captured_locals.rb +43 -3
- data/lib/rigor/inference/expression_typer.rb +58 -25
- data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
- data/lib/rigor/inference/match_rebinding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
- data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
- data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +52 -3
- data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
- data/lib/rigor/inference/project_patched_methods.rb +2 -2
- data/lib/rigor/inference/project_patched_scanner.rb +38 -24
- data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
- data/lib/rigor/inference/refined_dispatch.rb +62 -14
- data/lib/rigor/inference/repeated_or_writes.rb +2 -0
- data/lib/rigor/inference/scope_indexer.rb +140 -5
- data/lib/rigor/inference/statement_evaluator.rb +223 -31
- data/lib/rigor/inference/untyped_rejoin.rb +75 -0
- data/lib/rigor/language_server/project_context.rb +56 -5
- data/lib/rigor/lens/anchor.rb +115 -0
- data/lib/rigor/lens/contract_reader.rb +118 -0
- data/lib/rigor/lens/document.rb +113 -0
- data/lib/rigor/lens/file_reader.rb +433 -0
- data/lib/rigor/lens/members.rb +153 -0
- data/lib/rigor/lens/parameters.rb +91 -0
- data/lib/rigor/lens/plugins.rb +40 -0
- data/lib/rigor/lens/run.rb +280 -0
- data/lib/rigor/lens/slot.rb +79 -0
- data/lib/rigor/lens.rb +40 -0
- data/lib/rigor/project_environment.rb +20 -1
- data/lib/rigor/protection/mutation_cache.rb +2 -1
- data/lib/rigor/scope.rb +22 -11
- data/lib/rigor/sig_gen/generator.rb +45 -5
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
- data/sig/rigor/scope.rbs +3 -0
- data/sig/rigor.rbs +6 -3
- data/skills/rigor-project-init/references/02-configure.md +2 -2
- metadata +18 -1
|
@@ -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}")
|
|
55
|
-
#
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
#
|
|
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
|
|
71
|
-
# anchor as well as the peeled one, since an undeclared receiver may be an alias of either. A
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
#
|
|
384
|
-
#
|
|
385
|
-
#
|
|
386
|
-
#
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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)
|