fiber_audit 0.1.0
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 +7 -0
- data/.fiber-audit.example.yml +33 -0
- data/CHANGELOG.md +32 -0
- data/README.md +150 -0
- data/bin/fiber-audit +5 -0
- data/lib/fiber_audit/audit.rb +288 -0
- data/lib/fiber_audit/cli.rb +245 -0
- data/lib/fiber_audit/configuration.rb +236 -0
- data/lib/fiber_audit/correlation/fingerprint.rb +26 -0
- data/lib/fiber_audit/errors.rb +8 -0
- data/lib/fiber_audit/execution_context.rb +47 -0
- data/lib/fiber_audit/findings/collection.rb +58 -0
- data/lib/fiber_audit/findings/confidence.rb +19 -0
- data/lib/fiber_audit/findings/evidence.rb +13 -0
- data/lib/fiber_audit/findings/finding.rb +76 -0
- data/lib/fiber_audit/findings/location.rb +9 -0
- data/lib/fiber_audit/findings/severity.rb +19 -0
- data/lib/fiber_audit/project.rb +96 -0
- data/lib/fiber_audit/reporters/base.rb +12 -0
- data/lib/fiber_audit/reporters/json.rb +34 -0
- data/lib/fiber_audit/reporters/schema.rb +574 -0
- data/lib/fiber_audit/reporters/text.rb +179 -0
- data/lib/fiber_audit/static/call_site.rb +71 -0
- data/lib/fiber_audit/static/call_site_extractor.rb +524 -0
- data/lib/fiber_audit/static/execution_context_resolver.rb +266 -0
- data/lib/fiber_audit/static/rules/base.rb +185 -0
- data/lib/fiber_audit/static/rules/blocking_subprocess.rb +94 -0
- data/lib/fiber_audit/static/rules/built_ins.rb +36 -0
- data/lib/fiber_audit/static/rules/direct_socket.rb +112 -0
- data/lib/fiber_audit/static/rules/io_select.rb +104 -0
- data/lib/fiber_audit/static/rules/net_http_in_request.rb +116 -0
- data/lib/fiber_audit/static/rules/registry.rb +123 -0
- data/lib/fiber_audit/static/rules/synchronization.rb +124 -0
- data/lib/fiber_audit/static/rules/thread_current_state.rb +113 -0
- data/lib/fiber_audit/static/rules/thread_join.rb +96 -0
- data/lib/fiber_audit/static/semantic_index.rb +300 -0
- data/lib/fiber_audit/suppressions/parser.rb +146 -0
- data/lib/fiber_audit/suppressions/store.rb +63 -0
- data/lib/fiber_audit/version.rb +5 -0
- data/lib/fiber_audit.rb +40 -0
- metadata +108 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
require_relative 'call_site'
|
|
5
|
+
|
|
6
|
+
module FiberAudit
|
|
7
|
+
module Static
|
|
8
|
+
# Extracts CallSite objects from Ruby source files using Prism.
|
|
9
|
+
# Performs single-pass parsing per file with receiver inference.
|
|
10
|
+
class CallSiteExtractor
|
|
11
|
+
# Error from parsing a single file
|
|
12
|
+
ParseError = Data.define(:path, :message, :line)
|
|
13
|
+
|
|
14
|
+
# Result of extraction across multiple files
|
|
15
|
+
Result = Data.define(:call_sites, :parse_errors)
|
|
16
|
+
|
|
17
|
+
# Well-known constants that can be resolved without semantic index
|
|
18
|
+
WELL_KNOWN_CONSTANTS = %w[
|
|
19
|
+
Kernel
|
|
20
|
+
Open3
|
|
21
|
+
IO
|
|
22
|
+
Process
|
|
23
|
+
Thread
|
|
24
|
+
Mutex
|
|
25
|
+
ConditionVariable
|
|
26
|
+
Monitor
|
|
27
|
+
MonitorMixin
|
|
28
|
+
TCPSocket
|
|
29
|
+
TCPServer
|
|
30
|
+
UDPSocket
|
|
31
|
+
UNIXSocket
|
|
32
|
+
UNIXServer
|
|
33
|
+
Socket
|
|
34
|
+
IPSocket
|
|
35
|
+
Net::HTTP
|
|
36
|
+
URI
|
|
37
|
+
OpenURI
|
|
38
|
+
ActiveSupport::CurrentAttributes
|
|
39
|
+
File
|
|
40
|
+
Dir
|
|
41
|
+
Pathname
|
|
42
|
+
Redis
|
|
43
|
+
Net::SMTP
|
|
44
|
+
Net::FTP
|
|
45
|
+
Net::IMAP
|
|
46
|
+
].to_set.freeze
|
|
47
|
+
|
|
48
|
+
def initialize(files:, semantic_index: nil)
|
|
49
|
+
@files = Array(files)
|
|
50
|
+
@sem = semantic_index
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Extract call sites from all files. Returns Result.
|
|
54
|
+
# Never raises; collects parse errors per file.
|
|
55
|
+
# Maintains deterministic order: files processed in provided order.
|
|
56
|
+
def call
|
|
57
|
+
call_sites = []
|
|
58
|
+
parse_errors = []
|
|
59
|
+
|
|
60
|
+
@files.each do |file_path|
|
|
61
|
+
extract_from_file(file_path, call_sites, parse_errors)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Result.new(call_sites: call_sites, parse_errors: parse_errors)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def extract_from_file(file_path, call_sites, parse_errors)
|
|
70
|
+
# Read file once
|
|
71
|
+
source = File.read(file_path)
|
|
72
|
+
|
|
73
|
+
# Parse once with Prism
|
|
74
|
+
parse_result = Prism.parse(source)
|
|
75
|
+
|
|
76
|
+
# Collect parse errors and skip traversal if any
|
|
77
|
+
if parse_result.errors.any?
|
|
78
|
+
parse_result.errors.each do |error|
|
|
79
|
+
parse_errors << ParseError.new(
|
|
80
|
+
path: file_path,
|
|
81
|
+
message: error.message,
|
|
82
|
+
line: error.location&.start_line
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Walk the AST
|
|
89
|
+
visitor = ASTVisitor.new(
|
|
90
|
+
file_path: file_path,
|
|
91
|
+
source: source,
|
|
92
|
+
semantic_index: @sem
|
|
93
|
+
)
|
|
94
|
+
visitor.visit(parse_result.value)
|
|
95
|
+
|
|
96
|
+
call_sites.concat(visitor.call_sites)
|
|
97
|
+
rescue Errno::ENOENT
|
|
98
|
+
parse_errors << ParseError.new(
|
|
99
|
+
path: file_path,
|
|
100
|
+
message: "No such file or directory: #{file_path}",
|
|
101
|
+
line: nil
|
|
102
|
+
)
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
parse_errors << ParseError.new(
|
|
105
|
+
path: file_path,
|
|
106
|
+
message: e.message,
|
|
107
|
+
line: nil
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Internal visitor that walks the AST and extracts call sites. Keeping the
|
|
112
|
+
# traversal state together makes scope propagation explicit.
|
|
113
|
+
# rubocop:disable Metrics/ClassLength
|
|
114
|
+
class ASTVisitor
|
|
115
|
+
attr_reader :call_sites
|
|
116
|
+
|
|
117
|
+
def initialize(file_path:, source:, semantic_index:)
|
|
118
|
+
@file_path = file_path
|
|
119
|
+
@source = source
|
|
120
|
+
@semantic_index = semantic_index
|
|
121
|
+
@call_sites = []
|
|
122
|
+
|
|
123
|
+
# Scope tracking stacks
|
|
124
|
+
@nesting_stack = [] # Array of constant names (class/module nesting)
|
|
125
|
+
@scope_stack = [] # Array of [class_or_module, method_name, method_kind]
|
|
126
|
+
@in_singleton_class = false # Track if inside `class << self`
|
|
127
|
+
|
|
128
|
+
# Assignment tracking: local variable -> {constant:, confidence:}
|
|
129
|
+
# Scoped per method; no ivars attached to Prism nodes.
|
|
130
|
+
@assignment_scope = {}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def visit(node)
|
|
134
|
+
return unless node
|
|
135
|
+
|
|
136
|
+
case node
|
|
137
|
+
when Prism::ClassNode
|
|
138
|
+
visit_class(node)
|
|
139
|
+
when Prism::ModuleNode
|
|
140
|
+
visit_module(node)
|
|
141
|
+
when Prism::SingletonClassNode
|
|
142
|
+
visit_singleton_class(node)
|
|
143
|
+
when Prism::DefNode
|
|
144
|
+
visit_def(node)
|
|
145
|
+
when Prism::IfNode, Prism::UnlessNode, Prism::CaseNode
|
|
146
|
+
visit_branching(node)
|
|
147
|
+
when Prism::CallNode
|
|
148
|
+
visit_call(node)
|
|
149
|
+
when Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode
|
|
150
|
+
visit_assignment(node)
|
|
151
|
+
else
|
|
152
|
+
visit_children(node)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
# --- Class/Module/SingletonClass visitors ---
|
|
159
|
+
|
|
160
|
+
def visit_class(node)
|
|
161
|
+
visit_constant_scope(node.constant_path, :instance) do
|
|
162
|
+
visit_children(node)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def visit_module(node)
|
|
167
|
+
visit_constant_scope(node.constant_path, :module) do
|
|
168
|
+
visit_children(node)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def visit_constant_scope(constant_path, kind)
|
|
173
|
+
qualified_name = qualified_declaration_name(constant_path)
|
|
174
|
+
@nesting_stack.push(qualified_name) if qualified_name
|
|
175
|
+
@scope_stack.push([qualified_name, nil, kind])
|
|
176
|
+
saved_scope = @assignment_scope
|
|
177
|
+
@assignment_scope = {}
|
|
178
|
+
|
|
179
|
+
yield
|
|
180
|
+
ensure
|
|
181
|
+
@assignment_scope = saved_scope
|
|
182
|
+
@scope_stack.pop
|
|
183
|
+
@nesting_stack.pop if qualified_name
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def visit_singleton_class(node)
|
|
187
|
+
# `class << self` or `class << expr` - mark as singleton for def method_kind
|
|
188
|
+
prev_singleton = @in_singleton_class
|
|
189
|
+
@in_singleton_class = true
|
|
190
|
+
visit_children(node)
|
|
191
|
+
@in_singleton_class = prev_singleton
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def visit_def(node)
|
|
195
|
+
method_name = node.name.to_s
|
|
196
|
+
method_kind = determine_method_kind(node)
|
|
197
|
+
|
|
198
|
+
enclosing = explicit_method_receiver(node) || current_enclosing_constant
|
|
199
|
+
@scope_stack.push([enclosing, method_name, method_kind])
|
|
200
|
+
|
|
201
|
+
saved_scope = @assignment_scope
|
|
202
|
+
@assignment_scope = {}
|
|
203
|
+
|
|
204
|
+
visit_children(node)
|
|
205
|
+
|
|
206
|
+
@assignment_scope = saved_scope
|
|
207
|
+
@scope_stack.pop
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# --- Branching visitors (if/unless/case) ---
|
|
211
|
+
# Assignments in branches cannot leak to outer scope conservatively.
|
|
212
|
+
|
|
213
|
+
def visit_branching(node)
|
|
214
|
+
# Each direct branch starts from the same pre-branch bindings. This
|
|
215
|
+
# prevents assignments in one branch from influencing calls in a
|
|
216
|
+
# sibling branch, and no branch-local assignment leaks afterward.
|
|
217
|
+
pre_scope = @assignment_scope.dup
|
|
218
|
+
node.compact_child_nodes.each do |child|
|
|
219
|
+
@assignment_scope = pre_scope.dup
|
|
220
|
+
visit(child)
|
|
221
|
+
end
|
|
222
|
+
ensure
|
|
223
|
+
@assignment_scope = pre_scope if pre_scope
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# --- Call node visitor ---
|
|
227
|
+
|
|
228
|
+
def visit_call(node)
|
|
229
|
+
# Extract call site with local assignment tracking (no ivars on nodes)
|
|
230
|
+
call_site = build_call_site(node)
|
|
231
|
+
@call_sites << call_site if call_site
|
|
232
|
+
|
|
233
|
+
# Recurse into receiver, arguments, and attached block body.
|
|
234
|
+
visit(node.receiver) if node.receiver
|
|
235
|
+
visit(node.arguments) if node.arguments
|
|
236
|
+
visit(node.block) if node.block
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def visit_assignment(node)
|
|
240
|
+
var_name = node.name.to_s
|
|
241
|
+
value_node = node.value
|
|
242
|
+
|
|
243
|
+
# Determine what's being assigned
|
|
244
|
+
assigned_info = compute_assignment_info(value_node)
|
|
245
|
+
|
|
246
|
+
if assigned_info
|
|
247
|
+
@assignment_scope[var_name] = assigned_info
|
|
248
|
+
else
|
|
249
|
+
# Unsupported assignment (not .new, not a constant, not a call) -> invalidate
|
|
250
|
+
@assignment_scope.delete(var_name)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
visit(value_node)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def visit_children(node)
|
|
257
|
+
node.compact_child_nodes.each do |child|
|
|
258
|
+
visit(child)
|
|
259
|
+
end
|
|
260
|
+
rescue NoMethodError
|
|
261
|
+
node.child_nodes.compact.each { |child| visit(child) }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# --- Assignment info computation ---
|
|
265
|
+
# Returns {constant: String|nil, confidence: Symbol} or nil to invalidate.
|
|
266
|
+
|
|
267
|
+
def compute_assignment_info(value_node)
|
|
268
|
+
case value_node
|
|
269
|
+
when Prism::CallNode
|
|
270
|
+
compute_call_assignment_info(value_node)
|
|
271
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
272
|
+
# Direct constant assignment: `x = SomeConst`
|
|
273
|
+
resolved = resolve_constant_name(extract_constant_from_node(value_node))
|
|
274
|
+
{ constant: resolved, confidence: :high } if resolved
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def compute_call_assignment_info(call_node)
|
|
279
|
+
receiver_const = extract_constant_from_node(call_node.receiver)
|
|
280
|
+
resolved = resolve_constant_name(receiver_const)
|
|
281
|
+
|
|
282
|
+
# Thread.current returns the current Thread instance. Keep this narrow:
|
|
283
|
+
# arbitrary singleton methods do not imply their receiver's type.
|
|
284
|
+
return { constant: resolved, confidence: :high } if call_node.name == :current && resolved == 'Thread'
|
|
285
|
+
|
|
286
|
+
if call_node.name == :new && call_node.receiver
|
|
287
|
+
# Constructor call: `x = SomeConst.new(...)`
|
|
288
|
+
if resolved
|
|
289
|
+
{ constant: resolved, confidence: :high }
|
|
290
|
+
else
|
|
291
|
+
# Unresolved or bare .new stays heuristic; never fabricate high.
|
|
292
|
+
{ constant: nil, confidence: :low }
|
|
293
|
+
end
|
|
294
|
+
else
|
|
295
|
+
# Builder/other call: `x = build_something`
|
|
296
|
+
{ constant: nil, confidence: :low }
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def resolve_constant_name(const_name)
|
|
301
|
+
return nil unless const_name
|
|
302
|
+
|
|
303
|
+
semantic_receiver_constant(const_name) || well_known_receiver_constant(const_name)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Check if a constant name is resolvable (well-known or semantic index)
|
|
307
|
+
def resolved_constant?(const_name)
|
|
308
|
+
!resolve_constant_name(const_name).nil?
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# --- Call site building ---
|
|
312
|
+
|
|
313
|
+
def build_call_site(node)
|
|
314
|
+
receiver_source = extract_receiver_source(node)
|
|
315
|
+
method_name = node.name
|
|
316
|
+
arguments = extract_arguments(node)
|
|
317
|
+
enclosing_symbol = build_enclosing_symbol
|
|
318
|
+
nesting = @nesting_stack.dup
|
|
319
|
+
receiver_constant = resolve_receiver_constant(node, receiver_source)
|
|
320
|
+
|
|
321
|
+
resolution = build_resolution(receiver_constant, receiver_source, method_name)
|
|
322
|
+
confidence = determine_confidence(receiver_constant, receiver_source)
|
|
323
|
+
|
|
324
|
+
CallSite.new(
|
|
325
|
+
path: @file_path,
|
|
326
|
+
line: node.location.start_line,
|
|
327
|
+
column: node.location.start_column,
|
|
328
|
+
receiver_source: receiver_source,
|
|
329
|
+
receiver_constant: receiver_constant,
|
|
330
|
+
method_name: method_name,
|
|
331
|
+
arguments: arguments,
|
|
332
|
+
enclosing_symbol: enclosing_symbol,
|
|
333
|
+
nesting: nesting,
|
|
334
|
+
execution_context: nil,
|
|
335
|
+
resolution: resolution,
|
|
336
|
+
confidence: confidence
|
|
337
|
+
)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# --- Receiver extraction ---
|
|
341
|
+
|
|
342
|
+
def extract_receiver_source(node)
|
|
343
|
+
return nil unless node.receiver
|
|
344
|
+
|
|
345
|
+
receiver = node.receiver
|
|
346
|
+
loc = receiver.location
|
|
347
|
+
@source[loc.start_offset...loc.end_offset]
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def extract_arguments(node)
|
|
351
|
+
return [] unless node.arguments
|
|
352
|
+
|
|
353
|
+
node.arguments.arguments.map do |arg|
|
|
354
|
+
loc = arg.location
|
|
355
|
+
@source[loc.start_offset...loc.end_offset]
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# --- Constant resolution ---
|
|
360
|
+
|
|
361
|
+
def resolve_receiver_constant(node, receiver_source)
|
|
362
|
+
return nil unless receiver_source
|
|
363
|
+
|
|
364
|
+
inferred = chained_constructor_constant(node) ||
|
|
365
|
+
assigned_receiver_constant(node) ||
|
|
366
|
+
direct_receiver_constant(node)
|
|
367
|
+
return inferred if inferred
|
|
368
|
+
|
|
369
|
+
semantic_receiver_constant(receiver_source) ||
|
|
370
|
+
well_known_receiver_constant(receiver_source)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def chained_constructor_constant(node)
|
|
374
|
+
return unless node.receiver.is_a?(Prism::CallNode) && node.receiver.name == :new
|
|
375
|
+
|
|
376
|
+
extract_chain_constructor(node.receiver)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def assigned_receiver_constant(node)
|
|
380
|
+
return unless node.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
381
|
+
|
|
382
|
+
info = @assignment_scope[node.receiver.name.to_s]
|
|
383
|
+
info[:constant] if info && info[:confidence] == :high
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def direct_receiver_constant(node)
|
|
387
|
+
return unless node.receiver.is_a?(Prism::ConstantReadNode) ||
|
|
388
|
+
node.receiver.is_a?(Prism::ConstantPathNode)
|
|
389
|
+
|
|
390
|
+
resolve_constant_name(extract_constant_from_node(node.receiver))
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def semantic_receiver_constant(receiver_source)
|
|
394
|
+
return unless @semantic_index
|
|
395
|
+
|
|
396
|
+
@nesting_stack.length.downto(0) do |length|
|
|
397
|
+
resolved = @semantic_index.resolve_constant(
|
|
398
|
+
receiver_source,
|
|
399
|
+
nesting: @nesting_stack.first(length)
|
|
400
|
+
)
|
|
401
|
+
return resolved.name if resolved
|
|
402
|
+
end
|
|
403
|
+
nil
|
|
404
|
+
rescue StandardError
|
|
405
|
+
nil
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def well_known_receiver_constant(receiver_source)
|
|
409
|
+
receiver_source if WELL_KNOWN_CONSTANTS.include?(receiver_source)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Extract constructor constant from a direct chain like Thread.new.join
|
|
413
|
+
def extract_chain_constructor(new_call)
|
|
414
|
+
return nil unless new_call.is_a?(Prism::CallNode) && new_call.name == :new
|
|
415
|
+
|
|
416
|
+
if new_call.receiver.is_a?(Prism::ConstantReadNode) ||
|
|
417
|
+
new_call.receiver.is_a?(Prism::ConstantPathNode)
|
|
418
|
+
return resolve_constant_name(extract_constant_from_node(new_call.receiver))
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
nil
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# --- Constant name extraction ---
|
|
425
|
+
|
|
426
|
+
def extract_constant_from_node(node)
|
|
427
|
+
return nil unless node
|
|
428
|
+
|
|
429
|
+
case node
|
|
430
|
+
when Prism::ConstantReadNode
|
|
431
|
+
node.name.to_s
|
|
432
|
+
when Prism::ConstantPathNode
|
|
433
|
+
build_constant_path_string(node)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Build fully qualified constant path string like "Net::HTTP"
|
|
438
|
+
def build_constant_path_string(node)
|
|
439
|
+
parts = []
|
|
440
|
+
current = node
|
|
441
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
442
|
+
parts.unshift(current.name.to_s)
|
|
443
|
+
current = current.parent
|
|
444
|
+
end
|
|
445
|
+
parts.unshift(current.name.to_s) if current.is_a?(Prism::ConstantReadNode)
|
|
446
|
+
parts.join('::')
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Return the fully qualified lexical declaration name. Ruby treats
|
|
450
|
+
# `class Outer::Inner` as one nesting entry, while nested class/module
|
|
451
|
+
# bodies add their fully qualified name to the existing lexical stack.
|
|
452
|
+
def qualified_declaration_name(node)
|
|
453
|
+
name = extract_constant_from_node(node)
|
|
454
|
+
return nil unless name
|
|
455
|
+
return name if name.include?('::') || @nesting_stack.empty?
|
|
456
|
+
|
|
457
|
+
"#{@nesting_stack.last}::#{name}"
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# --- Resolution and confidence ---
|
|
461
|
+
|
|
462
|
+
def build_resolution(receiver_constant, receiver_source, method_name)
|
|
463
|
+
if receiver_constant
|
|
464
|
+
"#{receiver_constant}.#{method_name}"
|
|
465
|
+
elsif receiver_source
|
|
466
|
+
"#{receiver_source}.#{method_name}"
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def determine_confidence(receiver_constant, receiver_source)
|
|
471
|
+
if receiver_constant
|
|
472
|
+
:high
|
|
473
|
+
elsif receiver_source
|
|
474
|
+
:low
|
|
475
|
+
else
|
|
476
|
+
:unknown
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# --- Enclosing symbol ---
|
|
481
|
+
# Calls in class/module body without a method have enclosing_symbol nil.
|
|
482
|
+
|
|
483
|
+
def build_enclosing_symbol
|
|
484
|
+
return nil if @scope_stack.empty?
|
|
485
|
+
|
|
486
|
+
# Find the most recent method in scope stack
|
|
487
|
+
@scope_stack.reverse.each do |const, method, kind|
|
|
488
|
+
next unless method
|
|
489
|
+
|
|
490
|
+
prefix = const || ''
|
|
491
|
+
case kind
|
|
492
|
+
when :instance
|
|
493
|
+
return prefix.empty? ? "##{method}" : "#{prefix}##{method}"
|
|
494
|
+
when :class
|
|
495
|
+
return prefix.empty? ? ".#{method}" : "#{prefix}.#{method}"
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# No method found (call in class/module body) -> nil
|
|
500
|
+
nil
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# --- Method kind determination ---
|
|
504
|
+
|
|
505
|
+
def determine_method_kind(node)
|
|
506
|
+
return :class if node.receiver || @in_singleton_class
|
|
507
|
+
|
|
508
|
+
:instance
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def explicit_method_receiver(node)
|
|
512
|
+
return unless node.receiver && !node.receiver.is_a?(Prism::SelfNode)
|
|
513
|
+
|
|
514
|
+
extract_constant_from_node(node.receiver)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def current_enclosing_constant
|
|
518
|
+
@nesting_stack.last
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
# rubocop:enable Metrics/ClassLength
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
end
|