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,356 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "adapter"
|
|
4
|
+
require_relative "../diagnostic"
|
|
5
|
+
require_relative "../error"
|
|
6
|
+
|
|
7
|
+
module RippleEffect
|
|
8
|
+
module StaticIndex
|
|
9
|
+
# Rubydex-backed implementation of {Adapter}.
|
|
10
|
+
#
|
|
11
|
+
# Rubydex names singleton methods `Order::<Order>#recent` and suffixes every
|
|
12
|
+
# method with `()`; we normalise to the documented canonical forms `Order.recent`
|
|
13
|
+
# and `Order#save` here, so no other part of the codebase has to know that.
|
|
14
|
+
#
|
|
15
|
+
# Rubydex also reports a call site's receiver only when it could resolve it.
|
|
16
|
+
# We never invent one: an unresolved receiver becomes a lower-confidence,
|
|
17
|
+
# name-based candidate in {Extractors::RubyStructure}, or a diagnostic.
|
|
18
|
+
class RubydexAdapter < Adapter
|
|
19
|
+
# Rubydex synthesises this document for Ruby's core classes. Those are not
|
|
20
|
+
# part of the analysed project, so we drop everything it contains.
|
|
21
|
+
BUILT_IN_URI = "rubydex:built-in"
|
|
22
|
+
|
|
23
|
+
# Rubydex models each mixin form as its own class.
|
|
24
|
+
MIXIN_TYPES = {
|
|
25
|
+
"Rubydex::Include" => :include,
|
|
26
|
+
"Rubydex::Prepend" => :prepend,
|
|
27
|
+
"Rubydex::Extend" => :extend
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# Rubydex rules meaning the source could not be read. Everything else it
|
|
31
|
+
# reports is a lint finding about code it parsed fine.
|
|
32
|
+
FATAL_RULES = %w[SyntaxError ParseError].freeze
|
|
33
|
+
|
|
34
|
+
# Rubydex reports zero-indexed lines. Git hunks, editors and our own output
|
|
35
|
+
# all count from one.
|
|
36
|
+
LINE_OFFSET = 1
|
|
37
|
+
|
|
38
|
+
attr_reader :diagnostics, :declarations, :method_references, :constant_references
|
|
39
|
+
|
|
40
|
+
# @param project [Project] used to make paths project-relative and to keep
|
|
41
|
+
# the index inside the project root
|
|
42
|
+
def initialize(project:)
|
|
43
|
+
super()
|
|
44
|
+
@project = project
|
|
45
|
+
@declarations = []
|
|
46
|
+
@method_references = []
|
|
47
|
+
@constant_references = []
|
|
48
|
+
@diagnostics = []
|
|
49
|
+
@by_path = Hash.new { |hash, key| hash[key] = [] }
|
|
50
|
+
@indexed = false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [String] e.g. "rubydex 0.4.1"
|
|
54
|
+
def self.backend_version
|
|
55
|
+
require "rubydex"
|
|
56
|
+
"rubydex #{Rubydex::VERSION}"
|
|
57
|
+
rescue LoadError
|
|
58
|
+
"rubydex (not installed)"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def backend_version = self.class.backend_version
|
|
62
|
+
|
|
63
|
+
# Indexes +paths+ and normalises everything Rubydex returns.
|
|
64
|
+
#
|
|
65
|
+
# A failure inside a single file is recorded as a diagnostic; only a failure
|
|
66
|
+
# to build the index at all raises.
|
|
67
|
+
#
|
|
68
|
+
# @param paths [Array<String>] absolute paths
|
|
69
|
+
# @return [self]
|
|
70
|
+
# @raise [IndexError] when Rubydex is unavailable or the index cannot be built
|
|
71
|
+
def index(paths:)
|
|
72
|
+
graph = build_graph(paths)
|
|
73
|
+
collect_declarations(graph)
|
|
74
|
+
collect_method_references(graph)
|
|
75
|
+
collect_constant_references(graph)
|
|
76
|
+
collect_rubydex_diagnostics(graph)
|
|
77
|
+
@indexed = true
|
|
78
|
+
self
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Boolean]
|
|
82
|
+
def indexed? = @indexed
|
|
83
|
+
|
|
84
|
+
# @return [Array<Declaration>] declarations in +path+, outermost first
|
|
85
|
+
def declarations_in(path:)
|
|
86
|
+
@by_path[path]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The innermost declaration whose line range covers +line+.
|
|
90
|
+
#
|
|
91
|
+
# Methods are preferred over the classes that contain them, which is what
|
|
92
|
+
# makes a one-line diff hunk resolve to a method rather than to its class.
|
|
93
|
+
#
|
|
94
|
+
# @return [Declaration, nil]
|
|
95
|
+
def declaration_at(path:, line:)
|
|
96
|
+
covering = @by_path[path].select do |declaration|
|
|
97
|
+
next false unless declaration.start_line
|
|
98
|
+
|
|
99
|
+
line.between?(declaration.start_line, declaration.end_line || declaration.start_line)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
covering.min_by do |declaration|
|
|
103
|
+
[(declaration.end_line || declaration.start_line) - declaration.start_line, declaration.method? ? 0 : 1]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def build_graph(paths)
|
|
110
|
+
require "rubydex"
|
|
111
|
+
|
|
112
|
+
graph = Rubydex::Graph.new
|
|
113
|
+
graph.index_all(Array(paths))
|
|
114
|
+
graph.resolve
|
|
115
|
+
graph
|
|
116
|
+
rescue LoadError => e
|
|
117
|
+
raise IndexError, "rubydex could not be loaded: #{e.message}"
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
raise IndexError, "rubydex failed to index the project: #{e.class}: #{e.message}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Rubydex's resolved view gives us fully-qualified entities; each carries its
|
|
123
|
+
# concrete definition sites, which is where locations and mixins live.
|
|
124
|
+
def collect_declarations(graph)
|
|
125
|
+
graph.declarations.each do |entity|
|
|
126
|
+
kind = declaration_kind(entity)
|
|
127
|
+
next unless kind
|
|
128
|
+
|
|
129
|
+
definitions = safe(entity, :definitions) || []
|
|
130
|
+
definitions.each do |definition|
|
|
131
|
+
path = relative_uri(definition)
|
|
132
|
+
next unless path
|
|
133
|
+
|
|
134
|
+
declaration = build_declaration(entity, definition, kind, path)
|
|
135
|
+
@declarations << declaration
|
|
136
|
+
@by_path[path] << declaration
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
@declarations.sort_by! { |d| [d.path, d.start_line || 0, d.qualified_name] }
|
|
141
|
+
@by_path.each_value { |list| list.sort_by! { |d| [d.start_line || 0, d.qualified_name] } }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def build_declaration(entity, definition, kind, path)
|
|
145
|
+
location = definition.location
|
|
146
|
+
|
|
147
|
+
Declaration.new(
|
|
148
|
+
kind: kind,
|
|
149
|
+
qualified_name: canonical_name(entity.name, kind),
|
|
150
|
+
name: display_name(entity.name, kind),
|
|
151
|
+
path: path,
|
|
152
|
+
start_line: one_indexed(location.start_line),
|
|
153
|
+
end_line: one_indexed(location.end_line),
|
|
154
|
+
owner_name: canonical_owner(entity),
|
|
155
|
+
superclass_name: superclass_name(definition),
|
|
156
|
+
mixins: mixins_for(definition)
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def declaration_kind(entity)
|
|
161
|
+
case entity
|
|
162
|
+
when defined?(Rubydex::Method) ? Rubydex::Method : nil
|
|
163
|
+
singleton_owner?(entity) ? :class_method : :instance_method
|
|
164
|
+
when defined?(Rubydex::Class) ? Rubydex::Class : nil
|
|
165
|
+
:class
|
|
166
|
+
when defined?(Rubydex::Module) ? Rubydex::Module : nil
|
|
167
|
+
:module
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# A singleton method is one whose owner is Rubydex's `Foo::<Foo>` shadow class.
|
|
172
|
+
def singleton_owner?(entity)
|
|
173
|
+
owner = safe(entity, :owner)
|
|
174
|
+
owner ? owner.name.to_s.include?("::<") : false
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# "Commerce::Order::<Order>#recent()" -> "Commerce::Order.recent"
|
|
178
|
+
# "Commerce::Order#capture_payment()" -> "Commerce::Order#capture_payment"
|
|
179
|
+
def canonical_name(raw, kind)
|
|
180
|
+
name = raw.to_s.delete_suffix("()")
|
|
181
|
+
|
|
182
|
+
case kind
|
|
183
|
+
when :class_method
|
|
184
|
+
namespace, method = name.split("#", 2)
|
|
185
|
+
"#{namespace.to_s.sub(/::<[^>]+>\z/, '')}.#{method}"
|
|
186
|
+
else
|
|
187
|
+
name
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def display_name(raw, kind) = canonical_name(raw, kind)
|
|
192
|
+
|
|
193
|
+
def canonical_owner(entity)
|
|
194
|
+
owner = safe(entity, :owner)
|
|
195
|
+
return nil unless owner
|
|
196
|
+
|
|
197
|
+
name = owner.name.to_s.sub(/::<[^>]+>\z/, "")
|
|
198
|
+
name == "Object" ? nil : name
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def superclass_name(definition)
|
|
202
|
+
reference = safe(definition, :superclass)
|
|
203
|
+
return nil unless reference
|
|
204
|
+
|
|
205
|
+
resolved_target(reference)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def mixins_for(definition)
|
|
209
|
+
(safe(definition, :mixins) || []).filter_map do |mixin|
|
|
210
|
+
type = mixin_type(mixin)
|
|
211
|
+
next unless type
|
|
212
|
+
|
|
213
|
+
reference = safe(mixin, :constant_reference)
|
|
214
|
+
target = reference && (resolved_target(reference) || safe(reference, :name))
|
|
215
|
+
next unless target
|
|
216
|
+
|
|
217
|
+
{ type: type, target: target.to_s }
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def mixin_type(mixin)
|
|
222
|
+
MIXIN_TYPES[mixin.class.name]
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def collect_method_references(graph)
|
|
226
|
+
graph.method_references.each do |reference|
|
|
227
|
+
path = relative_uri(reference)
|
|
228
|
+
next unless path
|
|
229
|
+
|
|
230
|
+
line = one_indexed(reference.location.start_line)
|
|
231
|
+
|
|
232
|
+
@method_references << MethodReference.new(
|
|
233
|
+
name: reference.name.to_s,
|
|
234
|
+
receiver_name: receiver_name(reference),
|
|
235
|
+
path: path,
|
|
236
|
+
line: line,
|
|
237
|
+
enclosing_name: enclosing_name(path, line)
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
@method_references.sort_by! { |r| [r.path, r.line, r.name] }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Rubydex populates the receiver only when it resolved it, so a nil here is
|
|
245
|
+
# information, not a gap to paper over.
|
|
246
|
+
def receiver_name(reference)
|
|
247
|
+
receiver = safe(reference, :receiver)
|
|
248
|
+
return nil unless receiver
|
|
249
|
+
|
|
250
|
+
name = safe(receiver, :name)
|
|
251
|
+
return nil unless name
|
|
252
|
+
|
|
253
|
+
name.to_s.sub(/::<[^>]+>\z/, "")
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def collect_constant_references(graph)
|
|
257
|
+
graph.constant_references.each do |reference|
|
|
258
|
+
path = relative_uri(reference)
|
|
259
|
+
next unless path
|
|
260
|
+
|
|
261
|
+
name = safe(reference, :name) || safe(safe(reference, :declaration), :name)
|
|
262
|
+
next unless name
|
|
263
|
+
# Rubydex emits a synthetic `<Foo>` reference for each singleton class body.
|
|
264
|
+
next if name.to_s.start_with?("<")
|
|
265
|
+
|
|
266
|
+
line = one_indexed(reference.location.start_line)
|
|
267
|
+
|
|
268
|
+
@constant_references << ConstantReference.new(
|
|
269
|
+
name: name.to_s,
|
|
270
|
+
target_name: resolved_target(reference),
|
|
271
|
+
path: path,
|
|
272
|
+
line: line,
|
|
273
|
+
enclosing_name: enclosing_name(path, line)
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
@constant_references.sort_by! { |r| [r.path, r.line, r.name] }
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def resolved_target(reference)
|
|
281
|
+
declaration = safe(reference, :declaration)
|
|
282
|
+
return nil unless declaration
|
|
283
|
+
|
|
284
|
+
name = safe(declaration, :name)
|
|
285
|
+
name&.to_s&.delete_suffix("()")
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Most Rubydex diagnostics are lint findings ("assigned but unused
|
|
289
|
+
# variable", "Dynamic mixin argument") against files it parsed fine.
|
|
290
|
+
# Reporting those as parse failures would be wrong and would drown the
|
|
291
|
+
# diagnostics that actually limit the analysis, so they are recorded as
|
|
292
|
+
# informational notes. Only a rule meaning the source could not be read
|
|
293
|
+
# counts as a parse failure.
|
|
294
|
+
def collect_rubydex_diagnostics(graph)
|
|
295
|
+
(safe(graph, :diagnostics) || []).each do |diagnostic|
|
|
296
|
+
path = relative_uri(diagnostic)
|
|
297
|
+
next unless path
|
|
298
|
+
|
|
299
|
+
rule = rule_name(diagnostic)
|
|
300
|
+
fatal = FATAL_RULES.include?(rule)
|
|
301
|
+
|
|
302
|
+
@diagnostics << Diagnostic.new(
|
|
303
|
+
code: fatal ? "unparsed_file" : "index_note",
|
|
304
|
+
severity: fatal ? :warning : :info,
|
|
305
|
+
path: path,
|
|
306
|
+
line: one_indexed(safe(safe(diagnostic, :location), :start_line)),
|
|
307
|
+
message: [rule, safe(diagnostic, :message).to_s].compact.join(": ")
|
|
308
|
+
)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# @return [String, nil] e.g. "ParseWarning"
|
|
313
|
+
def rule_name(diagnostic)
|
|
314
|
+
rule = safe(diagnostic, :rule)
|
|
315
|
+
name = rule && safe(rule, :name)
|
|
316
|
+
return nil unless name
|
|
317
|
+
|
|
318
|
+
name.to_s.split("::").last
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# The declaration enclosing a reference, so we know who is doing the calling.
|
|
322
|
+
def enclosing_name(path, line)
|
|
323
|
+
declaration_at(path: path, line: line)&.qualified_name
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# @return [String, nil] project-relative path, or nil for built-ins and
|
|
327
|
+
# anything outside the project root
|
|
328
|
+
def relative_uri(object)
|
|
329
|
+
location = safe(object, :location)
|
|
330
|
+
uri = location && safe(location, :uri)
|
|
331
|
+
return nil if uri.nil? || uri.to_s == BUILT_IN_URI
|
|
332
|
+
|
|
333
|
+
absolute = uri.to_s.delete_prefix("file://")
|
|
334
|
+
return nil unless @project.inside_root?(absolute)
|
|
335
|
+
|
|
336
|
+
relative = @project.relative_path(absolute)
|
|
337
|
+
relative.start_with?("/") ? nil : relative
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# @return [Integer, nil] +line+ shifted into one-indexed space
|
|
341
|
+
def one_indexed(line)
|
|
342
|
+
line.nil? ? nil : line + LINE_OFFSET
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Rubydex's surface is still moving; a missing accessor should degrade the
|
|
346
|
+
# analysis, not crash it.
|
|
347
|
+
def safe(object, method)
|
|
348
|
+
return nil unless object.respond_to?(method)
|
|
349
|
+
|
|
350
|
+
object.public_send(method)
|
|
351
|
+
rescue StandardError
|
|
352
|
+
nil
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../confidence"
|
|
4
|
+
|
|
5
|
+
module RippleEffect
|
|
6
|
+
module Traversal
|
|
7
|
+
# One impacted node, together with the reason it is impacted.
|
|
8
|
+
#
|
|
9
|
+
# The evidence path is the product: it is what lets a developer check our
|
|
10
|
+
# answer instead of trusting it.
|
|
11
|
+
class Impact
|
|
12
|
+
attr_reader :node, :depth, :confidence, :evidence_path, :source_id
|
|
13
|
+
|
|
14
|
+
# @param node [Node] the impacted node
|
|
15
|
+
# @param depth [Integer] hops from the changed node
|
|
16
|
+
# @param evidence_path [Array<Edge>] edges from the changed node outward
|
|
17
|
+
# @param source_id [String] the changed node this impact originated from
|
|
18
|
+
def initialize(node:, depth:, evidence_path:, source_id:)
|
|
19
|
+
@node = node
|
|
20
|
+
@depth = depth
|
|
21
|
+
@evidence_path = evidence_path.freeze
|
|
22
|
+
@source_id = source_id
|
|
23
|
+
@confidence = Confidence.weakest(evidence_path.map(&:confidence))
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Edge, nil] the edge that reaches this node
|
|
28
|
+
def reason = evidence_path.last
|
|
29
|
+
|
|
30
|
+
# @return [Hash] JSON-compatible representation
|
|
31
|
+
def to_h
|
|
32
|
+
node.to_h.merge(
|
|
33
|
+
"depth" => depth,
|
|
34
|
+
"confidence" => confidence.to_s,
|
|
35
|
+
"source_id" => source_id,
|
|
36
|
+
"evidence_path" => evidence_path.map do |edge|
|
|
37
|
+
{
|
|
38
|
+
"type" => edge.type.to_s,
|
|
39
|
+
"evidence" => edge.evidence,
|
|
40
|
+
"confidence" => edge.confidence.to_s,
|
|
41
|
+
"location" => edge.location
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Breadth-first reverse traversal from a set of changed nodes.
|
|
49
|
+
#
|
|
50
|
+
# Breadth-first, so the reported path is the shortest chain of reasoning and
|
|
51
|
+
# therefore the quickest to verify by hand. Where two paths are equally short,
|
|
52
|
+
# the more confident one wins.
|
|
53
|
+
class ImpactWalker
|
|
54
|
+
# @param graph [Graph]
|
|
55
|
+
# @param min_confidence [Symbol] edges weaker than this are not traversed
|
|
56
|
+
# @param edge_types [Array<Symbol>, nil] restrict traversal to these types
|
|
57
|
+
# @param exclude_edge_types [Array<Symbol>] never traverse these types
|
|
58
|
+
def initialize(graph:, min_confidence: Confidence::MEDIUM, edge_types: nil, exclude_edge_types: [])
|
|
59
|
+
@graph = graph
|
|
60
|
+
@min_confidence = Confidence.cast(min_confidence)
|
|
61
|
+
@edge_types = edge_types&.map { |type| Edge.cast_type(type) }
|
|
62
|
+
@exclude_edge_types = exclude_edge_types.map { |type| Edge.cast_type(type) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Walks outward from every node in +from_ids+.
|
|
66
|
+
#
|
|
67
|
+
# @param from_ids [Array<String>]
|
|
68
|
+
# @param depth [Integer, nil] maximum hops, nil for unlimited
|
|
69
|
+
# @param direction [Symbol] :dependents (reverse) or :dependencies (forward)
|
|
70
|
+
# @return [Array<Impact>] sorted by depth, then node ID
|
|
71
|
+
def walk(from_ids, depth: nil, direction: :dependents)
|
|
72
|
+
best = {}
|
|
73
|
+
starts = Array(from_ids).select { |id| @graph.node?(id) }.sort
|
|
74
|
+
# Never report a changed node as impacted by itself.
|
|
75
|
+
seeds = starts.to_h { |id| [id, true] }
|
|
76
|
+
|
|
77
|
+
starts.each do |start_id|
|
|
78
|
+
traverse(start_id, depth, direction, seeds, best)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
best.values.sort_by { |impact| [impact.depth, impact.node.id] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def traverse(start_id, max_depth, direction, seeds, best)
|
|
87
|
+
# Queue entries are [node_id, evidence path so far].
|
|
88
|
+
queue = [[start_id, []]]
|
|
89
|
+
visited = { start_id => 0 }
|
|
90
|
+
|
|
91
|
+
until queue.empty?
|
|
92
|
+
current_id, path = queue.shift
|
|
93
|
+
current_depth = path.length
|
|
94
|
+
next if max_depth && current_depth >= max_depth
|
|
95
|
+
|
|
96
|
+
edges_from(current_id, direction).each do |edge|
|
|
97
|
+
next unless traversable?(edge)
|
|
98
|
+
|
|
99
|
+
neighbour_id = direction == :dependents ? edge.from_id : edge.into_id
|
|
100
|
+
next_depth = current_depth + 1
|
|
101
|
+
|
|
102
|
+
node = @graph.node(neighbour_id)
|
|
103
|
+
next unless node
|
|
104
|
+
|
|
105
|
+
existing = visited[neighbour_id]
|
|
106
|
+
# A strictly longer route tells us nothing we do not already know.
|
|
107
|
+
next if existing && existing < next_depth
|
|
108
|
+
|
|
109
|
+
next_path = path + [edge]
|
|
110
|
+
|
|
111
|
+
# Only continue outward the first time we reach a node at this depth;
|
|
112
|
+
# an equally short alternative still goes to #record, which decides
|
|
113
|
+
# between two same-length explanations on confidence.
|
|
114
|
+
if existing.nil?
|
|
115
|
+
visited[neighbour_id] = next_depth
|
|
116
|
+
queue << [neighbour_id, next_path]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
next if seeds[neighbour_id]
|
|
120
|
+
|
|
121
|
+
record(best, node, next_depth, next_path, start_id)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Keeps the shortest path, breaking ties on confidence: of two equally short
|
|
127
|
+
# explanations, we show the one we are surest of.
|
|
128
|
+
def record(best, node, depth, path, source_id)
|
|
129
|
+
impact = Impact.new(node: node, depth: depth, evidence_path: path, source_id: source_id)
|
|
130
|
+
current = best[node.id]
|
|
131
|
+
|
|
132
|
+
return best[node.id] = impact if current.nil?
|
|
133
|
+
return if current.depth < depth
|
|
134
|
+
return best[node.id] = impact if depth < current.depth
|
|
135
|
+
|
|
136
|
+
best[node.id] = impact if Confidence.rank(impact.confidence) > Confidence.rank(current.confidence)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def edges_from(id, direction)
|
|
140
|
+
edges = direction == :dependents ? @graph.incoming(id) : @graph.outgoing(id)
|
|
141
|
+
edges.sort_by { |edge| [edge.from_id, edge.into_id, edge.type.to_s] }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def traversable?(edge)
|
|
145
|
+
return false unless Confidence.at_least?(edge.confidence, @min_confidence)
|
|
146
|
+
return false if @exclude_edge_types.include?(edge.type)
|
|
147
|
+
return false if @edge_types && !@edge_types.include?(edge.type)
|
|
148
|
+
|
|
149
|
+
true
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RippleEffect
|
|
4
|
+
VERSION = "0.1.0"
|
|
5
|
+
|
|
6
|
+
# Bumped whenever the machine-readable result envelope changes shape.
|
|
7
|
+
SCHEMA_VERSION = 1
|
|
8
|
+
|
|
9
|
+
# Bumped whenever the on-disk cache layout changes.
|
|
10
|
+
CACHE_SCHEMA_VERSION = 1
|
|
11
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ripple_effect/version"
|
|
4
|
+
require_relative "ripple_effect/error"
|
|
5
|
+
require_relative "ripple_effect/confidence"
|
|
6
|
+
require_relative "ripple_effect/node"
|
|
7
|
+
require_relative "ripple_effect/edge"
|
|
8
|
+
require_relative "ripple_effect/diagnostic"
|
|
9
|
+
require_relative "ripple_effect/graph"
|
|
10
|
+
|
|
11
|
+
# Ripple Effect maps the blast radius of Ruby on Rails changes.
|
|
12
|
+
#
|
|
13
|
+
# It combines Ruby-aware static indexing with Rails semantics (callbacks,
|
|
14
|
+
# associations, routes, jobs, mailers, concerns and test references) to explain
|
|
15
|
+
# what may be affected before you refactor.
|
|
16
|
+
#
|
|
17
|
+
# The analysis is deterministic and local. Ripple Effect never boots the target
|
|
18
|
+
# application, evaluates its source, connects to a database, or makes a network call.
|
|
19
|
+
#
|
|
20
|
+
# @example Inspect one symbol
|
|
21
|
+
# result = RippleEffect.analyze("BillingService#charge")
|
|
22
|
+
# result.impacted_nodes.map(&:name)
|
|
23
|
+
#
|
|
24
|
+
# @example Analyse a Git range
|
|
25
|
+
# result = RippleEffect.diff("main")
|
|
26
|
+
# result.risk.level # => :high
|
|
27
|
+
module RippleEffect
|
|
28
|
+
class << self
|
|
29
|
+
# Analyses the blast radius of a single symbol.
|
|
30
|
+
#
|
|
31
|
+
# @param symbol [String] e.g. "BillingService#charge", "User", "User.find"
|
|
32
|
+
# @param root [String] project root
|
|
33
|
+
# @param options [Hash] forwarded to {Analyzer#inspect_symbol}
|
|
34
|
+
# @return [Result]
|
|
35
|
+
def analyze(symbol, root: Dir.pwd, **)
|
|
36
|
+
analyzer(root: root).inspect_symbol(symbol, **)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Analyses everything changed between +base+ and +head+.
|
|
40
|
+
#
|
|
41
|
+
# @param base [String] a Git ref
|
|
42
|
+
# @param head [String, nil] a Git ref, or nil to compare against the working tree
|
|
43
|
+
# @param root [String] project root
|
|
44
|
+
# @return [Result]
|
|
45
|
+
def diff(base, head: nil, root: Dir.pwd, **)
|
|
46
|
+
analyzer(root: root).diff(base: base, head: head, **)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Ranked test files likely relevant to a symbol.
|
|
50
|
+
#
|
|
51
|
+
# @param symbol [String]
|
|
52
|
+
# @return [Array<String>] project-relative test paths, most relevant first
|
|
53
|
+
def tests_for(symbol, root: Dir.pwd, **)
|
|
54
|
+
analyze(symbol, root: root, **).test_files
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def analyzer(root:)
|
|
60
|
+
Analyzer.new(project: Project.new(root: root))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
require_relative "ripple_effect/configuration"
|
|
66
|
+
require_relative "ripple_effect/project"
|
|
67
|
+
require_relative "ripple_effect/risk"
|
|
68
|
+
require_relative "ripple_effect/result"
|
|
69
|
+
require_relative "ripple_effect/cache_store"
|
|
70
|
+
require_relative "ripple_effect/static_index/adapter"
|
|
71
|
+
require_relative "ripple_effect/static_index/rubydex_adapter"
|
|
72
|
+
require_relative "ripple_effect/extractors/base"
|
|
73
|
+
require_relative "ripple_effect/extractors/ruby_structure"
|
|
74
|
+
require_relative "ripple_effect/extractors/rails_associations"
|
|
75
|
+
require_relative "ripple_effect/extractors/rails_callbacks"
|
|
76
|
+
require_relative "ripple_effect/extractors/rails_delegation"
|
|
77
|
+
require_relative "ripple_effect/extractors/rails_jobs"
|
|
78
|
+
require_relative "ripple_effect/extractors/rails_mailers"
|
|
79
|
+
require_relative "ripple_effect/extractors/rails_routes"
|
|
80
|
+
require_relative "ripple_effect/extractors/rails_views"
|
|
81
|
+
require_relative "ripple_effect/extractors/test_conventions"
|
|
82
|
+
require_relative "ripple_effect/traversal/impact_walker"
|
|
83
|
+
require_relative "ripple_effect/diff/git"
|
|
84
|
+
require_relative "ripple_effect/diff/hunk"
|
|
85
|
+
require_relative "ripple_effect/diff/changed_symbol_resolver"
|
|
86
|
+
require_relative "ripple_effect/formatters/text"
|
|
87
|
+
require_relative "ripple_effect/formatters/json"
|
|
88
|
+
require_relative "ripple_effect/formatters/dot"
|
|
89
|
+
require_relative "ripple_effect/analyzer"
|