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.
- checksums.yaml +7 -0
- data/.ripple-effect.yml.example +56 -0
- data/ARCHITECTURE.md +222 -0
- data/CHANGELOG.md +115 -0
- data/CODE_OF_CONDUCT.md +64 -0
- data/CONTRIBUTING.md +112 -0
- data/LICENSE.txt +21 -0
- data/README.md +305 -0
- data/SECURITY.md +73 -0
- data/docs/ANALYSIS_MODEL.md +275 -0
- data/docs/CLI.md +276 -0
- data/docs/CONFIGURATION.md +178 -0
- data/docs/DECISIONS.md +210 -0
- data/docs/PUBLIC_LAUNCH_CHECKLIST.md +105 -0
- data/docs/RELEASING.md +94 -0
- data/docs/TESTING.md +179 -0
- data/exe/ripple-effect +7 -0
- data/lib/ripple_effect/analyzer.rb +379 -0
- data/lib/ripple_effect/cache_store.rb +207 -0
- data/lib/ripple_effect/cli/application.rb +126 -0
- data/lib/ripple_effect/cli/command.rb +165 -0
- data/lib/ripple_effect/cli/diff_command.rb +76 -0
- data/lib/ripple_effect/cli/doctor_command.rb +106 -0
- data/lib/ripple_effect/cli/graph_command.rb +61 -0
- data/lib/ripple_effect/cli/inspect_command.rb +66 -0
- data/lib/ripple_effect/cli/tests_command.rb +109 -0
- data/lib/ripple_effect/cli/version_command.rb +46 -0
- data/lib/ripple_effect/confidence.rb +61 -0
- data/lib/ripple_effect/configuration.rb +264 -0
- data/lib/ripple_effect/diagnostic.rb +90 -0
- data/lib/ripple_effect/diff/changed_symbol_resolver.rb +292 -0
- data/lib/ripple_effect/diff/git.rb +175 -0
- data/lib/ripple_effect/diff/hunk.rb +80 -0
- data/lib/ripple_effect/edge.rb +114 -0
- data/lib/ripple_effect/error.rb +23 -0
- data/lib/ripple_effect/extractors/base.rb +292 -0
- data/lib/ripple_effect/extractors/rails_associations.rb +102 -0
- data/lib/ripple_effect/extractors/rails_callbacks.rb +144 -0
- data/lib/ripple_effect/extractors/rails_delegation.rb +121 -0
- data/lib/ripple_effect/extractors/rails_jobs.rb +131 -0
- data/lib/ripple_effect/extractors/rails_mailers.rb +120 -0
- data/lib/ripple_effect/extractors/rails_routes.rb +256 -0
- data/lib/ripple_effect/extractors/rails_views.rb +299 -0
- data/lib/ripple_effect/extractors/ruby_structure.rb +221 -0
- data/lib/ripple_effect/extractors/test_conventions.rb +135 -0
- data/lib/ripple_effect/formatters/dot.rb +69 -0
- data/lib/ripple_effect/formatters/json.rb +43 -0
- data/lib/ripple_effect/formatters/text.rb +197 -0
- data/lib/ripple_effect/graph.rb +199 -0
- data/lib/ripple_effect/node.rb +153 -0
- data/lib/ripple_effect/project.rb +264 -0
- data/lib/ripple_effect/result.rb +147 -0
- data/lib/ripple_effect/risk.rb +167 -0
- data/lib/ripple_effect/static_index/adapter.rb +84 -0
- data/lib/ripple_effect/static_index/rubydex_adapter.rb +356 -0
- data/lib/ripple_effect/traversal/impact_walker.rb +153 -0
- data/lib/ripple_effect/version.rb +11 -0
- data/lib/ripple_effect.rb +89 -0
- 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
|