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,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"