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,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module RippleEffect
|
|
6
|
+
module Extractors
|
|
7
|
+
# Extracts dependencies from ERB templates.
|
|
8
|
+
#
|
|
9
|
+
# In a server-rendered Rails app the views are a large part of the dependency
|
|
10
|
+
# graph: helpers, models and partials are used mostly from templates. Skipping
|
|
11
|
+
# them understates the blast radius of every helper while still looking like a
|
|
12
|
+
# complete answer.
|
|
13
|
+
#
|
|
14
|
+
# The Ruby is lifted out of the ERB tags and parsed with Prism, so the same
|
|
15
|
+
# resolution and confidence rules apply as anywhere else. Templates are never
|
|
16
|
+
# rendered and never executed.
|
|
17
|
+
#
|
|
18
|
+
# Also handles `helper_method`, which is what makes a controller method
|
|
19
|
+
# callable from a template.
|
|
20
|
+
class RailsViews < Base
|
|
21
|
+
# Matches an ERB tag, capturing the Ruby inside it.
|
|
22
|
+
ERB_TAG = /<%(={1,2}|-|\#)?(.*?)(-)?%>/m
|
|
23
|
+
|
|
24
|
+
# `render` forms that name another template.
|
|
25
|
+
RENDER_METHODS = %w[render render_to_string].freeze
|
|
26
|
+
|
|
27
|
+
# Rails looks a template up by name; a partial is the same name with an
|
|
28
|
+
# underscore prefix.
|
|
29
|
+
TEMPLATE_EXTENSIONS = %w[.html.erb .erb .turbo_stream.erb .text.erb .js.erb].freeze
|
|
30
|
+
|
|
31
|
+
def self.feature = :views
|
|
32
|
+
|
|
33
|
+
def extract
|
|
34
|
+
# `helper_method` is a real relationship whether or not templates are
|
|
35
|
+
# indexed, so it is not gated on there being any views to find.
|
|
36
|
+
link_helper_methods
|
|
37
|
+
|
|
38
|
+
return if project.view_paths.empty?
|
|
39
|
+
|
|
40
|
+
build_view_nodes
|
|
41
|
+
link_controller_actions
|
|
42
|
+
project.view_paths.each { |path| extract_from_template(path) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def build_view_nodes
|
|
48
|
+
project.view_paths.each do |path|
|
|
49
|
+
add_node(
|
|
50
|
+
Node.new(
|
|
51
|
+
kind: :view,
|
|
52
|
+
name: path,
|
|
53
|
+
qualified_name: path,
|
|
54
|
+
path: path,
|
|
55
|
+
metadata: { "partial" => partial?(path) }
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Node, nil]
|
|
62
|
+
def view_node(path) = graph.node("view:#{path}:0:#{path}")
|
|
63
|
+
|
|
64
|
+
# A controller action renders its conventional template, so a change to the
|
|
65
|
+
# template is reachable from the action, and so from its route.
|
|
66
|
+
def link_controller_actions
|
|
67
|
+
index.declarations.each do |declaration|
|
|
68
|
+
next unless declaration.kind == :instance_method
|
|
69
|
+
|
|
70
|
+
owner = declaration.owner_name
|
|
71
|
+
next unless owner&.end_with?("Controller")
|
|
72
|
+
|
|
73
|
+
action = declaration.qualified_name.split("#").last
|
|
74
|
+
template = conventional_template(owner, action)
|
|
75
|
+
next unless template
|
|
76
|
+
|
|
77
|
+
add_edge(
|
|
78
|
+
from: node_for(declaration.qualified_name),
|
|
79
|
+
into: view_node(template),
|
|
80
|
+
type: :file_reference,
|
|
81
|
+
evidence: "rails.renders_template",
|
|
82
|
+
confidence: Confidence::HIGH,
|
|
83
|
+
location: template,
|
|
84
|
+
metadata: { "template" => template, "action" => action }
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# `helper_method :current_company` is what makes a controller method
|
|
90
|
+
# reachable from a template. Without it, a change to that method looks
|
|
91
|
+
# confined to the controller when it is really used across the view layer.
|
|
92
|
+
def link_helper_methods
|
|
93
|
+
project.source_paths.each do |path|
|
|
94
|
+
next unless path.start_with?("app/controllers/")
|
|
95
|
+
|
|
96
|
+
each_call_in_class(sources.ast(path)) do |call, namespace|
|
|
97
|
+
next unless namespace
|
|
98
|
+
next unless call.name.to_s == "helper_method"
|
|
99
|
+
next unless call.receiver.nil?
|
|
100
|
+
|
|
101
|
+
literal_positional_names(call).each do |method_name|
|
|
102
|
+
link_exposed_method(namespace, method_name, path, call.location.start_line)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The method is usually defined on the controller but often comes from an
|
|
109
|
+
# included concern, which is why `helper_method` is worth modelling. A
|
|
110
|
+
# definition on the controller is a fact; a uniquely named one elsewhere is
|
|
111
|
+
# the same inference made for any other call.
|
|
112
|
+
def link_exposed_method(namespace, method_name, path, line)
|
|
113
|
+
own = node_for("#{namespace}##{method_name}")
|
|
114
|
+
candidates = own ? [own] : methods_named(method_name)
|
|
115
|
+
return unless candidates.length == 1
|
|
116
|
+
|
|
117
|
+
add_edge(
|
|
118
|
+
from: node_for(namespace),
|
|
119
|
+
into: candidates.first,
|
|
120
|
+
type: :method_call,
|
|
121
|
+
evidence: "rails.helper_method",
|
|
122
|
+
confidence: own ? Confidence::HIGH : Confidence::MEDIUM,
|
|
123
|
+
location: location_for(path, line),
|
|
124
|
+
metadata: { "method" => method_name, "exposed_to" => "views" }
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# "Admin::UsersController", "show" -> "app/views/admin/users/show.html.erb"
|
|
129
|
+
def conventional_template(controller, action)
|
|
130
|
+
directory = controller.delete_suffix("Controller")
|
|
131
|
+
.split("::")
|
|
132
|
+
.map { |part| underscore(part) }
|
|
133
|
+
.join("/")
|
|
134
|
+
|
|
135
|
+
find_template("#{directory}/#{action}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @param stem [String] e.g. "orders/show" or "shared/_menu"
|
|
139
|
+
# @return [String, nil] the matching indexed template path
|
|
140
|
+
def find_template(stem)
|
|
141
|
+
TEMPLATE_EXTENSIONS.each do |extension|
|
|
142
|
+
candidate = "app/views/#{stem}#{extension}"
|
|
143
|
+
return candidate if view_index.include?(candidate)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def view_index
|
|
150
|
+
@view_index ||= project.view_paths.to_h { |path| [path, true] }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def extract_from_template(path)
|
|
154
|
+
source = compile(path)
|
|
155
|
+
return unless source
|
|
156
|
+
|
|
157
|
+
result = Prism.parse(source)
|
|
158
|
+
unless result.success?
|
|
159
|
+
add_diagnostic(
|
|
160
|
+
code: "unparsed_file",
|
|
161
|
+
severity: :info,
|
|
162
|
+
path: path,
|
|
163
|
+
message: "could not parse the Ruby compiled from #{path}"
|
|
164
|
+
)
|
|
165
|
+
return
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
from = view_node(path)
|
|
169
|
+
return unless from
|
|
170
|
+
|
|
171
|
+
each_node(result.value) do |node|
|
|
172
|
+
case node
|
|
173
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
174
|
+
link_constant(from, node, path)
|
|
175
|
+
when Prism::CallNode
|
|
176
|
+
link_call(from, node, path)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Extracts the Ruby from a template as a plain sequence of statements.
|
|
182
|
+
#
|
|
183
|
+
# ERB's own compiler is not usable here. Rails templates rely on
|
|
184
|
+
# ActionView's handler, which understands `<%= form_with do |f| %>`;
|
|
185
|
+
# stdlib ERB compiles that to `_erbout << (form_with do |f|).to_s`, which
|
|
186
|
+
# is a syntax error. Emitting each tag's code as a bare statement keeps
|
|
187
|
+
# block forms and `<% end %>` balanced, and is all we need: we are looking
|
|
188
|
+
# for constants, calls and renders, not rendering anything.
|
|
189
|
+
def compile(path)
|
|
190
|
+
source = project.read(path)
|
|
191
|
+
return nil if source.nil?
|
|
192
|
+
|
|
193
|
+
statements = source.scan(ERB_TAG).filter_map do |marker, code, _trailing|
|
|
194
|
+
next if marker == "#" # `<%# a comment %>`
|
|
195
|
+
|
|
196
|
+
code.to_s.strip.delete_suffix("-")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
statements.reject(&:empty?).join("\n")
|
|
200
|
+
rescue ProjectError => e
|
|
201
|
+
add_diagnostic(code: "unparsed_file", severity: :info, path: path,
|
|
202
|
+
message: "could not read #{path}: #{e.message}")
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def link_constant(from, node, path)
|
|
207
|
+
name = constant_path_name(node)
|
|
208
|
+
return unless name
|
|
209
|
+
|
|
210
|
+
add_edge(
|
|
211
|
+
from: from,
|
|
212
|
+
into: node_for(name),
|
|
213
|
+
type: :constant_reference,
|
|
214
|
+
evidence: "view.constant_reference",
|
|
215
|
+
confidence: Confidence::HIGH,
|
|
216
|
+
location: path,
|
|
217
|
+
metadata: { "constant" => name }
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def link_call(from, node, path)
|
|
222
|
+
name = node.name.to_s
|
|
223
|
+
return link_render(from, node, path) if RENDER_METHODS.include?(name) && node.receiver.nil?
|
|
224
|
+
|
|
225
|
+
# An explicit receiver in a template is usually a local or instance
|
|
226
|
+
# variable of unknown type. Only bare calls (helpers and model-backed
|
|
227
|
+
# methods) are resolvable here.
|
|
228
|
+
return unless node.receiver.nil?
|
|
229
|
+
|
|
230
|
+
link_helper_call(from, name, path)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Same policy as everywhere else: a unique name is a medium-confidence
|
|
234
|
+
# inference, an ambiguous one is worth nothing.
|
|
235
|
+
def link_helper_call(from, name, path)
|
|
236
|
+
candidates = methods_named(name)
|
|
237
|
+
return unless candidates.length == 1
|
|
238
|
+
|
|
239
|
+
add_edge(
|
|
240
|
+
from: from,
|
|
241
|
+
into: candidates.first,
|
|
242
|
+
type: :method_call,
|
|
243
|
+
evidence: "view.helper_call",
|
|
244
|
+
confidence: Confidence::MEDIUM,
|
|
245
|
+
location: path,
|
|
246
|
+
metadata: { "method" => name }
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# `render "shared/menu"`, `render partial: "shared/menu"`.
|
|
251
|
+
def link_render(from, call, path)
|
|
252
|
+
target = literal_positional_names(call).first || literal_keywords(call)["partial"]
|
|
253
|
+
return unless target
|
|
254
|
+
|
|
255
|
+
template = resolve_partial(target, path)
|
|
256
|
+
return unless template
|
|
257
|
+
|
|
258
|
+
add_edge(
|
|
259
|
+
from: from,
|
|
260
|
+
into: view_node(template),
|
|
261
|
+
type: :file_reference,
|
|
262
|
+
evidence: "rails.render_partial",
|
|
263
|
+
confidence: Confidence::HIGH,
|
|
264
|
+
location: path,
|
|
265
|
+
metadata: { "partial" => target }
|
|
266
|
+
)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# A bare name resolves relative to the rendering template's own directory;
|
|
270
|
+
# a name with a slash is relative to app/views.
|
|
271
|
+
def resolve_partial(target, path)
|
|
272
|
+
# A dynamic partial name cannot be resolved statically.
|
|
273
|
+
return nil if target.include?("\#{")
|
|
274
|
+
|
|
275
|
+
if target.include?("/")
|
|
276
|
+
directory, base = target.split("/").then { |parts| [parts[0..-2].join("/"), parts.last] }
|
|
277
|
+
find_template("#{directory}/_#{base}")
|
|
278
|
+
else
|
|
279
|
+
directory = File.dirname(path).delete_prefix("app/views/")
|
|
280
|
+
find_template("#{directory}/_#{target}")
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def methods_named(name)
|
|
285
|
+
@methods_by_name ||= graph.nodes.select(&:method?).group_by do |node|
|
|
286
|
+
node.qualified_name.to_s.split(/[#.]/).last
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
@methods_by_name.fetch(name, [])
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def partial?(path) = File.basename(path).start_with?("_")
|
|
293
|
+
|
|
294
|
+
def underscore(name)
|
|
295
|
+
name.gsub(/([a-z\d])([A-Z])/, '\1_\2').gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').downcase
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module RippleEffect
|
|
6
|
+
module Extractors
|
|
7
|
+
# Turns the language-level index into graph nodes and edges: files, classes,
|
|
8
|
+
# modules, methods, inheritance, mixins, constant references and method calls.
|
|
9
|
+
#
|
|
10
|
+
# Every other extractor builds on the nodes this one creates, so it always runs
|
|
11
|
+
# first and is never optional.
|
|
12
|
+
class RubyStructure < Base
|
|
13
|
+
def extract
|
|
14
|
+
build_file_nodes
|
|
15
|
+
build_declaration_nodes
|
|
16
|
+
build_containment_edges
|
|
17
|
+
build_inheritance_and_mixin_edges
|
|
18
|
+
build_constant_reference_edges
|
|
19
|
+
build_method_call_edges
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def build_file_nodes
|
|
25
|
+
project.source_paths.each do |path|
|
|
26
|
+
add_node(
|
|
27
|
+
Node.new(
|
|
28
|
+
kind: project.test_path?(path) ? :test_file : :file,
|
|
29
|
+
name: path,
|
|
30
|
+
path: path,
|
|
31
|
+
qualified_name: path
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_declaration_nodes
|
|
38
|
+
index.declarations.each do |declaration|
|
|
39
|
+
add_node(
|
|
40
|
+
Node.new(
|
|
41
|
+
kind: declaration.kind,
|
|
42
|
+
name: declaration.qualified_name,
|
|
43
|
+
qualified_name: declaration.qualified_name,
|
|
44
|
+
path: declaration.path,
|
|
45
|
+
start_line: declaration.start_line,
|
|
46
|
+
end_line: declaration.end_line,
|
|
47
|
+
metadata: { "owner" => declaration.owner_name }.compact
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A file depends on what it declares: changing a class should reach the file
|
|
54
|
+
# node, and a test file's convention edges hang off the same structure.
|
|
55
|
+
def build_containment_edges
|
|
56
|
+
index.declarations.each do |declaration|
|
|
57
|
+
node = declaration_node(declaration)
|
|
58
|
+
next unless node
|
|
59
|
+
|
|
60
|
+
add_edge(
|
|
61
|
+
from: file_node_for(declaration.path),
|
|
62
|
+
into: node,
|
|
63
|
+
type: :file_reference,
|
|
64
|
+
evidence: "ruby.file_declares",
|
|
65
|
+
confidence: Confidence::HIGH,
|
|
66
|
+
location: node.location
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
next unless declaration.method? && declaration.owner_name
|
|
70
|
+
|
|
71
|
+
# The owning class depends on its methods: a change to `Order#total`
|
|
72
|
+
# is reachable from anything that reaches `Order`.
|
|
73
|
+
add_edge(
|
|
74
|
+
from: node_for(declaration.owner_name),
|
|
75
|
+
into: node,
|
|
76
|
+
type: :file_reference,
|
|
77
|
+
evidence: "ruby.defines_method",
|
|
78
|
+
confidence: Confidence::HIGH,
|
|
79
|
+
location: node.location
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_inheritance_and_mixin_edges
|
|
85
|
+
index.declarations.select(&:namespace?).each do |declaration|
|
|
86
|
+
node = declaration_node(declaration)
|
|
87
|
+
next unless node
|
|
88
|
+
|
|
89
|
+
if declaration.superclass_name
|
|
90
|
+
add_edge(
|
|
91
|
+
from: node,
|
|
92
|
+
into: node_for(declaration.superclass_name),
|
|
93
|
+
type: :inheritance,
|
|
94
|
+
evidence: "ruby.superclass",
|
|
95
|
+
confidence: Confidence::HIGH,
|
|
96
|
+
location: node.location,
|
|
97
|
+
metadata: { "superclass" => declaration.superclass_name }
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
declaration.mixins.each do |mixin|
|
|
102
|
+
add_edge(
|
|
103
|
+
from: node,
|
|
104
|
+
into: node_for(mixin[:target]),
|
|
105
|
+
type: mixin[:type],
|
|
106
|
+
evidence: "ruby.#{mixin[:type]}",
|
|
107
|
+
confidence: Confidence::HIGH,
|
|
108
|
+
location: node.location,
|
|
109
|
+
metadata: { "module" => mixin[:target] }
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_constant_reference_edges
|
|
116
|
+
index.constant_references.each do |reference|
|
|
117
|
+
next unless reference.resolved?
|
|
118
|
+
|
|
119
|
+
from = reference.enclosing_name ? node_for(reference.enclosing_name) : file_node_for(reference.path)
|
|
120
|
+
next unless from
|
|
121
|
+
|
|
122
|
+
add_edge(
|
|
123
|
+
from: from,
|
|
124
|
+
into: node_for(reference.target_name),
|
|
125
|
+
type: :constant_reference,
|
|
126
|
+
evidence: "rubydex.constant_reference",
|
|
127
|
+
confidence: Confidence::HIGH,
|
|
128
|
+
location: location_for(reference.path, reference.line),
|
|
129
|
+
metadata: { "constant" => reference.name }
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Call sites come in two flavours. When the indexer resolved the receiver we
|
|
135
|
+
# know exactly which method is meant. When it did not, we look the bare method
|
|
136
|
+
# name up in the index: a unique match is worth a medium-confidence edge, an
|
|
137
|
+
# ambiguous or unknown one is worth a diagnostic and nothing else.
|
|
138
|
+
def build_method_call_edges
|
|
139
|
+
index.method_references.each do |reference|
|
|
140
|
+
from = caller_node(reference)
|
|
141
|
+
next unless from
|
|
142
|
+
|
|
143
|
+
if reference.resolved_receiver?
|
|
144
|
+
link_resolved_call(reference, from)
|
|
145
|
+
else
|
|
146
|
+
link_inferred_call(reference, from)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def caller_node(reference)
|
|
152
|
+
if reference.enclosing_name
|
|
153
|
+
node_for(reference.enclosing_name) || file_node_for(reference.path)
|
|
154
|
+
else
|
|
155
|
+
file_node_for(reference.path)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def link_resolved_call(reference, from)
|
|
160
|
+
target = node_for("#{reference.receiver_name}##{reference.name}") ||
|
|
161
|
+
node_for("#{reference.receiver_name}.#{reference.name}")
|
|
162
|
+
|
|
163
|
+
return link_inferred_call(reference, from) if target.nil?
|
|
164
|
+
|
|
165
|
+
add_edge(
|
|
166
|
+
from: from,
|
|
167
|
+
into: target,
|
|
168
|
+
type: :method_call,
|
|
169
|
+
evidence: "rubydex.method_reference",
|
|
170
|
+
confidence: Confidence::HIGH,
|
|
171
|
+
location: location_for(reference.path, reference.line),
|
|
172
|
+
metadata: { "method" => reference.name, "receiver" => reference.receiver_name }
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def link_inferred_call(reference, from)
|
|
177
|
+
candidates = methods_named(reference.name)
|
|
178
|
+
|
|
179
|
+
case candidates.size
|
|
180
|
+
when 0
|
|
181
|
+
nil
|
|
182
|
+
when 1
|
|
183
|
+
add_edge(
|
|
184
|
+
from: from,
|
|
185
|
+
into: candidates.first,
|
|
186
|
+
type: :method_call,
|
|
187
|
+
evidence: "inference.unique_method_name",
|
|
188
|
+
confidence: Confidence::MEDIUM,
|
|
189
|
+
location: location_for(reference.path, reference.line),
|
|
190
|
+
metadata: { "method" => reference.name }
|
|
191
|
+
)
|
|
192
|
+
else
|
|
193
|
+
# Several methods share this name and we cannot tell which is meant.
|
|
194
|
+
# Guessing here is exactly the kind of confident falsehood that would
|
|
195
|
+
# cost the user their trust, so we record the ambiguity instead.
|
|
196
|
+
add_diagnostic(
|
|
197
|
+
code: "unresolved_method_receiver",
|
|
198
|
+
severity: :info,
|
|
199
|
+
path: reference.path,
|
|
200
|
+
line: reference.line,
|
|
201
|
+
message: "cannot resolve receiver for `#{reference.name}`; " \
|
|
202
|
+
"#{candidates.size} methods share that name"
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Index of bare method name -> method nodes, built once.
|
|
208
|
+
def methods_named(name)
|
|
209
|
+
@methods_by_name ||= graph.nodes.select(&:method?).group_by do |node|
|
|
210
|
+
node.qualified_name.to_s.split(/[#.]/).last
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
@methods_by_name.fetch(name, [])
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def declaration_node(declaration)
|
|
217
|
+
graph.find_symbol(declaration.qualified_name).find { |node| node.path == declaration.path }
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module RippleEffect
|
|
6
|
+
module Extractors
|
|
7
|
+
# Links test files to the code they cover.
|
|
8
|
+
#
|
|
9
|
+
# Two kinds of evidence, ranked differently. A test that references
|
|
10
|
+
# `BillingService` is strong evidence; one that merely sits at the
|
|
11
|
+
# conventional path is weaker. The structure extractor usually produces the
|
|
12
|
+
# strong edge already, so the convention edge is a safety net for tests that
|
|
13
|
+
# exercise code indirectly.
|
|
14
|
+
class TestConventions < Base
|
|
15
|
+
# app/services/billing_service.rb -> spec/services/billing_service_spec.rb
|
|
16
|
+
RSPEC_SUFFIX = "_spec.rb"
|
|
17
|
+
MINITEST_SUFFIX = "_test.rb"
|
|
18
|
+
|
|
19
|
+
# Controllers are also commonly covered by request specs named after the
|
|
20
|
+
# resource rather than the class, which is a weaker match.
|
|
21
|
+
REQUEST_SPEC_DIRECTORIES = %w[spec/requests spec/controllers test/controllers test/integration].freeze
|
|
22
|
+
|
|
23
|
+
def extract
|
|
24
|
+
project.test_paths.each do |test_path|
|
|
25
|
+
test_node = file_node_for(test_path)
|
|
26
|
+
next unless test_node
|
|
27
|
+
|
|
28
|
+
link_conventional_target(test_path, test_node)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
link_request_specs
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# spec/models/user_spec.rb -> app/models/user.rb
|
|
37
|
+
def link_conventional_target(test_path, test_node)
|
|
38
|
+
source_path = conventional_source_path(test_path)
|
|
39
|
+
return if source_path.nil?
|
|
40
|
+
|
|
41
|
+
source_node = file_node_for(source_path)
|
|
42
|
+
return if source_node.nil?
|
|
43
|
+
|
|
44
|
+
add_edge(
|
|
45
|
+
from: test_node, into: source_node,
|
|
46
|
+
type: :test_convention,
|
|
47
|
+
evidence: evidence_for(test_path),
|
|
48
|
+
confidence: Confidence::HIGH,
|
|
49
|
+
location: test_path,
|
|
50
|
+
metadata: { "source" => source_path }
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Reach the declarations in that file too, so inspecting a single method
|
|
54
|
+
# still surfaces the file's spec.
|
|
55
|
+
graph.nodes_by_path(source_path).each do |node|
|
|
56
|
+
next if node.kind == :file
|
|
57
|
+
|
|
58
|
+
add_edge(
|
|
59
|
+
from: test_node, into: node,
|
|
60
|
+
type: :test_convention,
|
|
61
|
+
evidence: evidence_for(test_path),
|
|
62
|
+
confidence: Confidence::MEDIUM,
|
|
63
|
+
location: test_path,
|
|
64
|
+
metadata: { "source" => source_path }
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def evidence_for(test_path)
|
|
70
|
+
test_path.end_with?(MINITEST_SUFFIX) ? "convention.minitest_path" : "convention.rspec_path"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def conventional_source_path(test_path)
|
|
74
|
+
stem = strip_suffix(test_path)
|
|
75
|
+
return nil if stem.nil?
|
|
76
|
+
|
|
77
|
+
relative = stem.split("/", 2).last
|
|
78
|
+
return nil if relative.nil?
|
|
79
|
+
|
|
80
|
+
candidates(relative).find { |candidate| project.indexed?(candidate) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def strip_suffix(test_path)
|
|
84
|
+
return test_path.delete_suffix(RSPEC_SUFFIX) if test_path.end_with?(RSPEC_SUFFIX)
|
|
85
|
+
return test_path.delete_suffix(MINITEST_SUFFIX) if test_path.end_with?(MINITEST_SUFFIX)
|
|
86
|
+
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# `spec/models/user` -> app/models/user.rb, then lib/models/user.rb, then
|
|
91
|
+
# the same name anywhere under app/.
|
|
92
|
+
def candidates(relative)
|
|
93
|
+
["app/#{relative}.rb", "lib/#{relative}.rb", "#{relative}.rb"]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# `spec/requests/orders_spec.rb` conventionally covers `OrdersController`,
|
|
97
|
+
# but the naming is looser, so this stays a low-confidence hint.
|
|
98
|
+
def link_request_specs
|
|
99
|
+
project.test_paths.each do |test_path|
|
|
100
|
+
next unless REQUEST_SPEC_DIRECTORIES.any? { |dir| test_path.start_with?("#{dir}/") }
|
|
101
|
+
|
|
102
|
+
test_node = file_node_for(test_path)
|
|
103
|
+
next unless test_node
|
|
104
|
+
|
|
105
|
+
controller = request_spec_controller(test_path)
|
|
106
|
+
next unless controller
|
|
107
|
+
|
|
108
|
+
add_edge(
|
|
109
|
+
from: test_node, into: node_for(controller),
|
|
110
|
+
type: :test_convention,
|
|
111
|
+
evidence: "convention.request_spec_path",
|
|
112
|
+
confidence: Confidence::LOW,
|
|
113
|
+
location: test_path,
|
|
114
|
+
metadata: { "controller" => controller }
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def request_spec_controller(test_path)
|
|
120
|
+
stem = strip_suffix(test_path)
|
|
121
|
+
return nil if stem.nil?
|
|
122
|
+
|
|
123
|
+
base = stem.split("/").last
|
|
124
|
+
return nil if base.nil?
|
|
125
|
+
|
|
126
|
+
name = base.end_with?("_controller") ? base.delete_suffix("_controller") : base
|
|
127
|
+
namespace = stem.split("/")[2..-2] || []
|
|
128
|
+
parts = (namespace + ["#{name}_controller"]).map { |part| camelize(part) }
|
|
129
|
+
candidate = parts.join("::")
|
|
130
|
+
|
|
131
|
+
node_for(candidate) ? candidate : nil
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|