ripple_effect 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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.ripple-effect.yml.example +56 -0
  3. data/ARCHITECTURE.md +222 -0
  4. data/CHANGELOG.md +115 -0
  5. data/CODE_OF_CONDUCT.md +64 -0
  6. data/CONTRIBUTING.md +112 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +305 -0
  9. data/SECURITY.md +73 -0
  10. data/docs/ANALYSIS_MODEL.md +275 -0
  11. data/docs/CLI.md +276 -0
  12. data/docs/CONFIGURATION.md +178 -0
  13. data/docs/DECISIONS.md +210 -0
  14. data/docs/PUBLIC_LAUNCH_CHECKLIST.md +105 -0
  15. data/docs/RELEASING.md +94 -0
  16. data/docs/TESTING.md +179 -0
  17. data/exe/ripple-effect +7 -0
  18. data/lib/ripple_effect/analyzer.rb +379 -0
  19. data/lib/ripple_effect/cache_store.rb +207 -0
  20. data/lib/ripple_effect/cli/application.rb +126 -0
  21. data/lib/ripple_effect/cli/command.rb +165 -0
  22. data/lib/ripple_effect/cli/diff_command.rb +76 -0
  23. data/lib/ripple_effect/cli/doctor_command.rb +106 -0
  24. data/lib/ripple_effect/cli/graph_command.rb +61 -0
  25. data/lib/ripple_effect/cli/inspect_command.rb +66 -0
  26. data/lib/ripple_effect/cli/tests_command.rb +109 -0
  27. data/lib/ripple_effect/cli/version_command.rb +46 -0
  28. data/lib/ripple_effect/confidence.rb +61 -0
  29. data/lib/ripple_effect/configuration.rb +264 -0
  30. data/lib/ripple_effect/diagnostic.rb +90 -0
  31. data/lib/ripple_effect/diff/changed_symbol_resolver.rb +292 -0
  32. data/lib/ripple_effect/diff/git.rb +175 -0
  33. data/lib/ripple_effect/diff/hunk.rb +80 -0
  34. data/lib/ripple_effect/edge.rb +114 -0
  35. data/lib/ripple_effect/error.rb +23 -0
  36. data/lib/ripple_effect/extractors/base.rb +292 -0
  37. data/lib/ripple_effect/extractors/rails_associations.rb +102 -0
  38. data/lib/ripple_effect/extractors/rails_callbacks.rb +144 -0
  39. data/lib/ripple_effect/extractors/rails_delegation.rb +121 -0
  40. data/lib/ripple_effect/extractors/rails_jobs.rb +131 -0
  41. data/lib/ripple_effect/extractors/rails_mailers.rb +120 -0
  42. data/lib/ripple_effect/extractors/rails_routes.rb +256 -0
  43. data/lib/ripple_effect/extractors/rails_views.rb +299 -0
  44. data/lib/ripple_effect/extractors/ruby_structure.rb +221 -0
  45. data/lib/ripple_effect/extractors/test_conventions.rb +135 -0
  46. data/lib/ripple_effect/formatters/dot.rb +69 -0
  47. data/lib/ripple_effect/formatters/json.rb +43 -0
  48. data/lib/ripple_effect/formatters/text.rb +197 -0
  49. data/lib/ripple_effect/graph.rb +199 -0
  50. data/lib/ripple_effect/node.rb +153 -0
  51. data/lib/ripple_effect/project.rb +264 -0
  52. data/lib/ripple_effect/result.rb +147 -0
  53. data/lib/ripple_effect/risk.rb +167 -0
  54. data/lib/ripple_effect/static_index/adapter.rb +84 -0
  55. data/lib/ripple_effect/static_index/rubydex_adapter.rb +356 -0
  56. data/lib/ripple_effect/traversal/impact_walker.rb +153 -0
  57. data/lib/ripple_effect/version.rb +11 -0
  58. data/lib/ripple_effect.rb +89 -0
  59. metadata +155 -0
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../node"
5
+ require_relative "../edge"
6
+ require_relative "../diagnostic"
7
+
8
+ module RippleEffect
9
+ module Extractors
10
+ # Parses each project file at most once and hands out the Prism AST.
11
+ #
12
+ # The declaration index tells us *that* `belongs_to` was called; only the AST
13
+ # tells us what it was called with. Extractors share this cache so a file with
14
+ # associations, callbacks and delegations is still parsed a single time.
15
+ class SourceCache
16
+ def initialize(project:)
17
+ @project = project
18
+ @asts = {}
19
+ @diagnostics = []
20
+ end
21
+
22
+ # @return [Array<Diagnostic>] parse failures collected so far
23
+ attr_reader :diagnostics
24
+
25
+ # @param path [String] project-relative path
26
+ # @return [Prism::Node, nil] the program's root node, or nil when unparsable
27
+ def ast(path)
28
+ return @asts[path] if @asts.key?(path)
29
+
30
+ @asts[path] = parse(path)
31
+ end
32
+
33
+ private
34
+
35
+ def parse(path)
36
+ source = @project.read(path)
37
+ return nil if source.nil?
38
+
39
+ result = Prism.parse(source)
40
+
41
+ unless result.success?
42
+ @diagnostics << Diagnostic.new(
43
+ code: "unparsed_file",
44
+ severity: :warning,
45
+ path: path,
46
+ line: result.errors.first&.location&.start_line,
47
+ message: "could not parse #{path}: #{result.errors.first&.message}"
48
+ )
49
+ return nil
50
+ end
51
+
52
+ result.value
53
+ rescue ProjectError, SystemCallError, ArgumentError => e
54
+ @diagnostics << Diagnostic.new(
55
+ code: "unparsed_file",
56
+ severity: :warning,
57
+ path: path,
58
+ message: "could not read #{path}: #{e.message}"
59
+ )
60
+ nil
61
+ end
62
+ end
63
+
64
+ # Everything an extractor needs, passed as one object so adding a shared
65
+ # facility later does not change every extractor's signature.
66
+ Context = Struct.new(:project, :index, :graph, :sources, :diagnostics, keyword_init: true)
67
+
68
+ # Base class for all extractors.
69
+ #
70
+ # An extractor's only job is to add nodes and edges to the graph. It must never
71
+ # execute application code, and must never add an edge it cannot evidence.
72
+ #
73
+ # @abstract Subclasses implement {#extract}.
74
+ class Base
75
+ # @return [Context]
76
+ attr_reader :context
77
+
78
+ def initialize(context)
79
+ @context = context
80
+ end
81
+
82
+ # @return [void]
83
+ def extract = raise NotImplementedError
84
+
85
+ # @return [String] the config key that switches this extractor off, or nil
86
+ # when the extractor is not optional
87
+ def self.feature = nil
88
+
89
+ private
90
+
91
+ def project = context.project
92
+ def index = context.index
93
+ def graph = context.graph
94
+ def sources = context.sources
95
+
96
+ def add_node(node) = graph.add_node(node)
97
+
98
+ # Adds an edge only when both endpoints exist, so a mistaken name can never
99
+ # create a dangling relationship.
100
+ #
101
+ # @return [Boolean] true when the edge was added
102
+ def add_edge(from:, into:, type:, evidence:, confidence:, location: nil, metadata: {})
103
+ return false if from.nil? || into.nil?
104
+
105
+ from_id = from.is_a?(Node) ? from.id : from
106
+ into_id = into.is_a?(Node) ? into.id : into
107
+ return false if from_id == into_id
108
+ return false unless graph.node?(from_id) && graph.node?(into_id)
109
+
110
+ graph.add_edge(
111
+ Edge.new(
112
+ from_id: from_id, into_id: into_id, type: type, evidence: evidence,
113
+ confidence: confidence, location: location, metadata: metadata
114
+ )
115
+ )
116
+ end
117
+
118
+ def add_diagnostic(code:, message:, path: nil, line: nil, severity: :warning)
119
+ context.diagnostics << Diagnostic.new(
120
+ code: code, message: message, path: path, line: line, severity: severity
121
+ )
122
+ end
123
+
124
+ # The single node for a qualified symbol name, or nil when the name is
125
+ # unknown or ambiguous. Ambiguity is not guessed away.
126
+ #
127
+ # @param name [String] e.g. "BillingService#charge"
128
+ # @return [Node, nil]
129
+ def node_for(name)
130
+ return nil if name.nil?
131
+
132
+ matches = graph.find_symbol(name)
133
+ matches.size == 1 ? matches.first : nil
134
+ end
135
+
136
+ # @return [Array<Node>] every node matching a qualified name
137
+ def nodes_for(name)
138
+ name.nil? ? [] : graph.find_symbol(name)
139
+ end
140
+
141
+ # @return [Node, nil] the file node for a project-relative path
142
+ def file_node_for(path)
143
+ graph.node("file:#{path}")
144
+ end
145
+
146
+ # Class and module declarations keyed by qualified name.
147
+ #
148
+ # Built once per extractor. Scanning the declaration list for each lookup
149
+ # turns ancestry walks into O(declarations) per class, which is quadratic on
150
+ # a large application.
151
+ #
152
+ # @return [Hash{String => StaticIndex::Declaration}]
153
+ def namespaces_by_name
154
+ @namespaces_by_name ||= index.declarations.select(&:namespace?).to_h do |declaration|
155
+ [declaration.qualified_name, declaration]
156
+ end
157
+ end
158
+
159
+ # Walks every node in the tree, depth first.
160
+ #
161
+ # @yieldparam node [Prism::Node]
162
+ def each_node(root, &block)
163
+ return unless root
164
+
165
+ stack = [root]
166
+ until stack.empty?
167
+ node = stack.pop
168
+ block.call(node)
169
+ node.compact_child_nodes.reverse_each { |child| stack << child }
170
+ end
171
+ end
172
+
173
+ # Walks call nodes paired with the innermost enclosing class/module path.
174
+ #
175
+ # @yieldparam call [Prism::CallNode]
176
+ # @yieldparam namespace [String, nil] e.g. "Commerce::Order"
177
+ def each_call_in_class(root, namespace: nil, &block)
178
+ return unless root
179
+
180
+ root.compact_child_nodes.each do |child|
181
+ case child
182
+ when Prism::ClassNode, Prism::ModuleNode
183
+ inner = join_namespace(namespace, constant_path_name(child.constant_path))
184
+ each_call_in_class(child.body, namespace: inner, &block)
185
+ when Prism::CallNode
186
+ block.call(child, namespace)
187
+ each_call_in_class(child, namespace: namespace, &block)
188
+ else
189
+ each_call_in_class(child, namespace: namespace, &block)
190
+ end
191
+ end
192
+ end
193
+
194
+ # @return [String, nil] "A::B" for a constant path node
195
+ def constant_path_name(node)
196
+ case node
197
+ when Prism::ConstantReadNode then node.name.to_s
198
+ when Prism::ConstantPathNode
199
+ parent = node.parent ? constant_path_name(node.parent) : nil
200
+ child = node.name&.to_s
201
+ return nil unless child
202
+
203
+ parent ? "#{parent}::#{child}" : child
204
+ end
205
+ end
206
+
207
+ def join_namespace(outer, inner)
208
+ return outer if inner.nil?
209
+
210
+ outer ? "#{outer}::#{inner}" : inner
211
+ end
212
+
213
+ # @return [String, nil] the literal value of a symbol or string argument
214
+ def literal_name(node)
215
+ case node
216
+ when Prism::SymbolNode, Prism::StringNode then node.unescaped
217
+ end
218
+ end
219
+
220
+ # Extracts literal keyword arguments from a call, ignoring anything dynamic.
221
+ #
222
+ # @return [Hash{String => Object}] only entries whose value is a literal
223
+ def literal_keywords(call)
224
+ arguments = call.arguments&.arguments || []
225
+ hash = arguments.last
226
+ return {} unless hash.is_a?(Prism::KeywordHashNode)
227
+
228
+ hash.elements.each_with_object({}) do |element, result|
229
+ next unless element.is_a?(Prism::AssocNode)
230
+
231
+ key = literal_name(element.key)
232
+ next unless key
233
+
234
+ value = literal_value(element.value)
235
+ result[key] = value unless value.nil?
236
+ end
237
+ end
238
+
239
+ def literal_value(node)
240
+ case node
241
+ when Prism::SymbolNode, Prism::StringNode then node.unescaped
242
+ when Prism::TrueNode then true
243
+ when Prism::FalseNode then false
244
+ when Prism::ArrayNode then node.elements.filter_map { |element| literal_value(element) }
245
+ end
246
+ end
247
+
248
+ # Positional arguments before any keyword hash.
249
+ #
250
+ # @return [Array<Prism::Node>]
251
+ def positional_arguments(call)
252
+ arguments = call.arguments&.arguments || []
253
+ arguments.grep_v(Prism::KeywordHashNode)
254
+ end
255
+
256
+ # @return [Array<String>] literal symbol/string positional arguments
257
+ def literal_positional_names(call)
258
+ positional_arguments(call).filter_map { |argument| literal_name(argument) }
259
+ end
260
+
261
+ # Rails' `Commerce::LineItem` <-> `:line_items` naming, applied only where
262
+ # Rails itself would apply it.
263
+ #
264
+ # @param name [String] e.g. "line_items"
265
+ # @return [String] e.g. "LineItem"
266
+ def classify(name)
267
+ singular = singularize(name.to_s)
268
+ singular.split("/").map { |part| camelize(part) }.join("::")
269
+ end
270
+
271
+ def camelize(name)
272
+ name.split("_").map { |part| part.empty? ? part : part[0].upcase + part[1..] }.join
273
+ end
274
+
275
+ # A small inflector. ActiveSupport is never loaded, and an unresolved target
276
+ # is reported rather than guessed at high confidence.
277
+ def singularize(name)
278
+ case name
279
+ when /(?:s|sh|ch|x|z)es\z/ then name.sub(/es\z/, "")
280
+ when /ies\z/ then name.sub(/ies\z/, "y")
281
+ when /s\z/ then name.end_with?("ss") ? name : name.sub(/s\z/, "")
282
+ else name
283
+ end
284
+ end
285
+
286
+ # @return [String] "path:line"
287
+ def location_for(path, line)
288
+ "#{path}:#{line}"
289
+ end
290
+ end
291
+ end
292
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RippleEffect
6
+ module Extractors
7
+ # Extracts `belongs_to` / `has_one` / `has_many` / `has_and_belongs_to_many`
8
+ # relationships between models.
9
+ #
10
+ # Only literal forms are read. Scopes and blocks are never executed, and a
11
+ # polymorphic target has no static answer, so it is reported as a diagnostic
12
+ # rather than resolved to an invented constant.
13
+ class RailsAssociations < Base
14
+ MACROS = %w[belongs_to has_one has_many has_and_belongs_to_many].freeze
15
+
16
+ # `has_many :orders` implies a plural target; `belongs_to :account` a singular one.
17
+ PLURAL_MACROS = %w[has_many has_and_belongs_to_many].freeze
18
+
19
+ def self.feature = :associations
20
+
21
+ def extract
22
+ project.source_paths.each do |path|
23
+ next if project.test_path?(path)
24
+
25
+ each_call_in_class(sources.ast(path)) do |call, namespace|
26
+ next unless namespace
27
+ next unless MACROS.include?(call.name.to_s)
28
+ next unless call.receiver.nil?
29
+
30
+ extract_association(call, namespace, path)
31
+ end
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def extract_association(call, namespace, path)
38
+ association_name = literal_positional_names(call).first
39
+ return if association_name.nil?
40
+
41
+ keywords = literal_keywords(call)
42
+ macro = call.name.to_s
43
+ line = call.location.start_line
44
+ model = node_for(namespace)
45
+ return if model.nil?
46
+
47
+ if keywords["polymorphic"] == true
48
+ add_diagnostic(
49
+ code: "unresolved_polymorphic_association",
50
+ path: path,
51
+ line: line,
52
+ message: "cannot statically resolve polymorphic #{macro} :#{association_name}"
53
+ )
54
+ return
55
+ end
56
+
57
+ target_name = keywords["class_name"] || conventional_class_name(association_name, macro)
58
+ target = node_for(target_name)
59
+
60
+ if target.nil?
61
+ add_diagnostic(
62
+ code: "unresolved_association_target",
63
+ severity: :info,
64
+ path: path,
65
+ line: line,
66
+ message: "#{macro} :#{association_name} points at #{target_name}, which is not indexed"
67
+ )
68
+ return
69
+ end
70
+
71
+ add_edge(
72
+ from: model,
73
+ into: target,
74
+ type: :association,
75
+ evidence: "rails.#{macro}",
76
+ # An explicit class_name is a fact; the conventional name is an inference,
77
+ # but one Rails itself makes, so it stays high once the target is indexed.
78
+ confidence: Confidence::HIGH,
79
+ location: location_for(path, line),
80
+ metadata: association_metadata(macro, association_name, keywords, target_name)
81
+ )
82
+ end
83
+
84
+ def association_metadata(macro, association_name, keywords, target_name)
85
+ {
86
+ "macro" => macro,
87
+ "association_name" => association_name,
88
+ "class_name" => target_name,
89
+ "foreign_key" => keywords["foreign_key"],
90
+ "through" => keywords["through"],
91
+ "polymorphic" => false
92
+ }.compact
93
+ end
94
+
95
+ def conventional_class_name(association_name, macro)
96
+ name = association_name.to_s
97
+ name = singularize(name) if PLURAL_MACROS.include?(macro)
98
+ classify(name)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RippleEffect
6
+ module Extractors
7
+ # Extracts ActiveRecord lifecycle callbacks.
8
+ #
9
+ # A callback is the classic invisible dependency: nothing in the source calls
10
+ # `send_receipt`, yet saving an Order runs it. Each callback becomes its own
11
+ # node so the reason for the impact survives into the explanation.
12
+ class RailsCallbacks < Base
13
+ MACROS = %w[
14
+ before_validation after_validation
15
+ before_save around_save after_save
16
+ before_create around_create after_create
17
+ before_update around_update after_update
18
+ before_destroy around_destroy after_destroy
19
+ after_commit after_rollback
20
+ after_touch after_initialize after_find
21
+ ].freeze
22
+
23
+ # Callbacks that accept a literal method name and may also carry conditions.
24
+ CONDITION_KEYS = %w[if unless].freeze
25
+
26
+ def self.feature = :callbacks
27
+
28
+ def extract
29
+ project.source_paths.each do |path|
30
+ next if project.test_path?(path)
31
+
32
+ each_call_in_class(sources.ast(path)) do |call, namespace|
33
+ next unless namespace
34
+ next unless MACROS.include?(call.name.to_s)
35
+ next unless call.receiver.nil?
36
+
37
+ extract_callback(call, namespace, path)
38
+ end
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def extract_callback(call, namespace, path)
45
+ owner = node_for(namespace)
46
+ return if owner.nil?
47
+
48
+ macro = call.name.to_s
49
+ line = call.location.start_line
50
+ targets = literal_positional_names(call)
51
+
52
+ if targets.empty?
53
+ extract_block_callback(call, owner, macro, path, line) if call.block
54
+ return
55
+ end
56
+
57
+ keywords = literal_keywords(call)
58
+
59
+ targets.each do |target_name|
60
+ link_callback(owner: owner, macro: macro, target_name: target_name, path: path, line: line,
61
+ keywords: keywords, namespace: namespace)
62
+ end
63
+ end
64
+
65
+ def link_callback(owner:, macro:, target_name:, path:, line:, keywords:, namespace:)
66
+ callback_node = add_node(
67
+ Node.new(
68
+ kind: :callback,
69
+ name: "#{namespace} #{macro} :#{target_name}",
70
+ qualified_name: "#{namespace}.#{macro}.#{target_name}",
71
+ path: path,
72
+ start_line: line,
73
+ end_line: line,
74
+ metadata: { "macro" => macro, "method" => target_name, "model" => namespace }.merge(
75
+ condition_metadata(keywords)
76
+ )
77
+ )
78
+ )
79
+
80
+ # The model owns its lifecycle, and the lifecycle invokes the method.
81
+ add_edge(
82
+ from: owner, into: callback_node, type: :callback,
83
+ evidence: "rails.#{macro}", confidence: Confidence::HIGH,
84
+ location: location_for(path, line)
85
+ )
86
+
87
+ add_edge(
88
+ from: callback_node, into: node_for("#{namespace}##{target_name}"),
89
+ type: :callback, evidence: "rails.#{macro}", confidence: Confidence::HIGH,
90
+ location: location_for(path, line), metadata: { "macro" => macro }
91
+ )
92
+
93
+ link_conditions(callback_node, keywords, namespace, path, line)
94
+ end
95
+
96
+ # `if: :publishable?` runs on every save too, so a change to the predicate
97
+ # is in the blast radius.
98
+ def link_conditions(callback_node, keywords, namespace, path, line)
99
+ CONDITION_KEYS.each do |key|
100
+ Array(keywords[key]).each do |condition|
101
+ next unless condition.is_a?(String)
102
+
103
+ add_edge(
104
+ from: callback_node, into: node_for("#{namespace}##{condition}"),
105
+ type: :callback, evidence: "rails.callback_condition",
106
+ confidence: Confidence::HIGH, location: location_for(path, line),
107
+ metadata: { "condition" => key, "method" => condition }
108
+ )
109
+ end
110
+ end
111
+ end
112
+
113
+ # An inline block has no method name to point at, but its body is ordinary
114
+ # code: the structure extractor already indexed the calls inside it, so we
115
+ # only need a node to anchor them to.
116
+ def extract_block_callback(call, owner, macro, path, line)
117
+ callback_node = add_node(
118
+ Node.new(
119
+ kind: :callback,
120
+ name: "#{owner.qualified_name} #{macro} (block)",
121
+ qualified_name: "#{owner.qualified_name}.#{macro}.block@#{line}",
122
+ path: path,
123
+ start_line: line,
124
+ end_line: call.block.location.end_line,
125
+ metadata: { "macro" => macro, "block" => true, "model" => owner.qualified_name }
126
+ )
127
+ )
128
+
129
+ add_edge(
130
+ from: owner, into: callback_node, type: :callback,
131
+ evidence: "rails.#{macro}", confidence: Confidence::HIGH,
132
+ location: location_for(path, line), metadata: { "block" => true }
133
+ )
134
+ end
135
+
136
+ def condition_metadata(keywords)
137
+ CONDITION_KEYS.each_with_object({}) do |key, result|
138
+ value = keywords[key]
139
+ result[key] = Array(value) if value
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RippleEffect
6
+ module Extractors
7
+ # Extracts ActiveSupport's `delegate :name, to: :user`.
8
+ #
9
+ # Delegation names a *receiver*, not a class. When that receiver is itself an
10
+ # association or a method we have already resolved, we can follow it; otherwise
11
+ # we record the delegation as a fact without pretending to know the target.
12
+ class RailsDelegation < Base
13
+ def self.feature = :delegation
14
+
15
+ def extract
16
+ project.source_paths.each do |path|
17
+ next if project.test_path?(path)
18
+
19
+ each_call_in_class(sources.ast(path)) do |call, namespace|
20
+ next unless namespace
21
+ next unless call.name.to_s == "delegate"
22
+ next unless call.receiver.nil?
23
+
24
+ extract_delegation(call, namespace, path)
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def extract_delegation(call, namespace, path)
32
+ methods = literal_positional_names(call)
33
+ return if methods.empty?
34
+
35
+ keywords = literal_keywords(call)
36
+ target = keywords["to"]
37
+ return if target.nil?
38
+
39
+ owner = node_for(namespace)
40
+ return if owner.nil?
41
+
42
+ line = call.location.start_line
43
+ target_class = resolve_target_class(target, namespace)
44
+
45
+ methods.each do |method_name|
46
+ link_delegation(
47
+ owner: owner, method_name: method_name, target: target, target_class: target_class,
48
+ prefix: keywords["prefix"], namespace: namespace, path: path, line: line
49
+ )
50
+ end
51
+ end
52
+
53
+ def link_delegation(owner:, method_name:, target:, target_class:, prefix:, namespace:, path:, line:)
54
+ if target_class.nil?
55
+ add_diagnostic(
56
+ code: "unresolved_delegate_target",
57
+ severity: :info,
58
+ path: path,
59
+ line: line,
60
+ message: "delegate :#{method_name}, to: :#{target}: cannot resolve `#{target}`"
61
+ )
62
+ return
63
+ end
64
+
65
+ target_node = node_for("#{target_class}##{method_name}")
66
+
67
+ if target_node
68
+ add_edge(
69
+ from: owner, into: target_node, type: :delegate,
70
+ evidence: "rails.delegate", confidence: Confidence::MEDIUM,
71
+ location: location_for(path, line),
72
+ metadata: delegate_metadata(method_name, target, target_class, prefix, namespace)
73
+ )
74
+ else
75
+ # We resolved the receiver's class but it does not define the method --
76
+ # it may come from a superclass, a concern, or method_missing.
77
+ add_edge(
78
+ from: owner, into: node_for(target_class), type: :delegate,
79
+ evidence: "rails.delegate", confidence: Confidence::MEDIUM,
80
+ location: location_for(path, line),
81
+ metadata: delegate_metadata(method_name, target, target_class, prefix, namespace)
82
+ )
83
+ end
84
+ end
85
+
86
+ def delegate_metadata(method_name, target, target_class, prefix, namespace)
87
+ {
88
+ "method" => method_name,
89
+ "to" => target,
90
+ "target_class" => target_class,
91
+ "prefix" => prefix ? true : false,
92
+ "defined_on" => namespace
93
+ }
94
+ end
95
+
96
+ # A delegation target is a method name. The strongest static reading is an
97
+ # association of the same name on the same class, which the association
98
+ # extractor has already recorded.
99
+ def resolve_target_class(target, namespace)
100
+ return nil if target.to_s.start_with?("@") || target.to_s == "class"
101
+
102
+ association = association_target(namespace, target)
103
+ return association if association
104
+
105
+ candidate = classify(target.to_s)
106
+ node_for(candidate) ? candidate : nil
107
+ end
108
+
109
+ def association_target(namespace, target)
110
+ owner = node_for(namespace)
111
+ return nil unless owner
112
+
113
+ edge = graph.outgoing(owner.id).find do |candidate|
114
+ candidate.type == :association && candidate.metadata["association_name"] == target.to_s
115
+ end
116
+
117
+ edge && edge.metadata["class_name"]
118
+ end
119
+ end
120
+ end
121
+ end