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 "hunk"
|
|
5
|
+
require_relative "../diagnostic"
|
|
6
|
+
|
|
7
|
+
module RippleEffect
|
|
8
|
+
module Diff
|
|
9
|
+
# Works out which symbols a diff actually changed.
|
|
10
|
+
#
|
|
11
|
+
# Treating every declaration in a touched file as changed gives a one-line fix
|
|
12
|
+
# an enormous, useless blast radius. Instead each changed line range maps to
|
|
13
|
+
# the smallest declaration enclosing it.
|
|
14
|
+
#
|
|
15
|
+
# Deleted code is the awkward case: it only exists in the base revision, so
|
|
16
|
+
# that content is parsed separately from the index of the current tree.
|
|
17
|
+
class ChangedSymbolResolver
|
|
18
|
+
# A symbol the diff touched.
|
|
19
|
+
Changed = Struct.new(:node, :path, :reason, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
# @param project [Project]
|
|
22
|
+
# @param index [StaticIndex::Adapter] index of the current tree
|
|
23
|
+
# @param graph [Graph]
|
|
24
|
+
# @param git [Git]
|
|
25
|
+
def initialize(project:, index:, graph:, git:)
|
|
26
|
+
@project = project
|
|
27
|
+
@index = index
|
|
28
|
+
@graph = graph
|
|
29
|
+
@git = git
|
|
30
|
+
@diagnostics = []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Array<Diagnostic>] collected while resolving
|
|
34
|
+
attr_reader :diagnostics
|
|
35
|
+
|
|
36
|
+
# Resolves a set of Git changes into changed graph nodes.
|
|
37
|
+
#
|
|
38
|
+
# @param changes [Array<Git::Change>]
|
|
39
|
+
# @param base [String] the base ref, needed to read deleted content
|
|
40
|
+
# @param head [String, nil]
|
|
41
|
+
# @return [Array<Node>] deduplicated, sorted by ID
|
|
42
|
+
def resolve(changes:, base:, head: nil)
|
|
43
|
+
nodes = {}
|
|
44
|
+
|
|
45
|
+
changes.each do |change|
|
|
46
|
+
flag_global_file(change)
|
|
47
|
+
|
|
48
|
+
if change.deleted?
|
|
49
|
+
resolve_deleted_file(change, base, nodes)
|
|
50
|
+
elsif change.ruby?
|
|
51
|
+
resolve_ruby_change(change, base, head, nodes)
|
|
52
|
+
else
|
|
53
|
+
add_file_node(change.path, nodes)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
nodes.values.sort_by(&:id)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# A change to a boot-impact file can affect almost anything, so we record it
|
|
63
|
+
# rather than let a narrow answer look authoritative.
|
|
64
|
+
def flag_global_file(change)
|
|
65
|
+
return unless @project.global_file?(change.path)
|
|
66
|
+
|
|
67
|
+
@diagnostics << Diagnostic.new(
|
|
68
|
+
code: "global_file_changed",
|
|
69
|
+
severity: :warning,
|
|
70
|
+
path: change.path,
|
|
71
|
+
message: "#{change.path} affects application boot or configuration; " \
|
|
72
|
+
"impact may be broader than the graph shows"
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def resolve_ruby_change(change, base, head, nodes)
|
|
77
|
+
if change.renamed?
|
|
78
|
+
@diagnostics << Diagnostic.new(
|
|
79
|
+
code: "renamed_file",
|
|
80
|
+
severity: :info,
|
|
81
|
+
path: change.path,
|
|
82
|
+
message: "renamed from #{change.old_path}"
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
hunks = Hunk.parse(@git.file_diff(base: base, head: head, path: change.path))
|
|
87
|
+
|
|
88
|
+
if hunks.empty?
|
|
89
|
+
add_file_node(change.path, nodes)
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
added = map_new_lines(change.path, hunks, nodes)
|
|
94
|
+
deleted = map_deleted_lines(change, base, nodes)
|
|
95
|
+
|
|
96
|
+
return if added || deleted
|
|
97
|
+
|
|
98
|
+
# We saw hunks but could attribute none of them to a declaration.
|
|
99
|
+
@diagnostics << Diagnostic.new(
|
|
100
|
+
code: "unmapped_changed_lines",
|
|
101
|
+
severity: :warning,
|
|
102
|
+
path: change.path,
|
|
103
|
+
message: "could not map changed lines in #{change.path} to a declaration; " \
|
|
104
|
+
"treating the whole file as changed"
|
|
105
|
+
)
|
|
106
|
+
add_file_node(change.path, nodes)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Added and modified lines exist in the current tree, so the live index can
|
|
110
|
+
# answer which declaration encloses them.
|
|
111
|
+
def map_new_lines(path, hunks, nodes)
|
|
112
|
+
matched = false
|
|
113
|
+
|
|
114
|
+
hunks.each do |hunk|
|
|
115
|
+
range = hunk.new_range
|
|
116
|
+
next unless range
|
|
117
|
+
|
|
118
|
+
declarations = range.filter_map { |line| @index.declaration_at(path: path, line: line) }.uniq
|
|
119
|
+
|
|
120
|
+
if declarations.empty?
|
|
121
|
+
# Lines inside a file but outside any declaration: requires,
|
|
122
|
+
# constants, or a class body statement. The file itself changed.
|
|
123
|
+
matched = true if add_file_node(path, nodes)
|
|
124
|
+
next
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
declarations.each do |declaration|
|
|
128
|
+
matched = true if add_declaration_node(declaration, nodes)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
matched
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Deleted lines exist only in the base revision.
|
|
136
|
+
def map_deleted_lines(change, base, nodes)
|
|
137
|
+
deletions = Hunk.parse(@git.file_diff(base: base, head: nil, path: change.path))
|
|
138
|
+
.select { |hunk| hunk.old_count.positive? }
|
|
139
|
+
return false if deletions.empty?
|
|
140
|
+
|
|
141
|
+
source = @git.show(ref: base, path: change.old_path || change.path)
|
|
142
|
+
return false if source.nil?
|
|
143
|
+
|
|
144
|
+
outline = Outline.parse(source)
|
|
145
|
+
matched = false
|
|
146
|
+
|
|
147
|
+
deletions.each do |hunk|
|
|
148
|
+
range = hunk.old_range
|
|
149
|
+
next unless range
|
|
150
|
+
|
|
151
|
+
range.filter_map { |line| outline.declaration_at(line) }.uniq.each do |name|
|
|
152
|
+
matched = true if add_named_node(name, change.path, nodes)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
matched
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# A file that no longer exists has no current declarations; we record what it
|
|
160
|
+
# used to define so its former dependents still show up.
|
|
161
|
+
def resolve_deleted_file(change, base, nodes)
|
|
162
|
+
@diagnostics << Diagnostic.new(
|
|
163
|
+
code: "deleted_file",
|
|
164
|
+
severity: :warning,
|
|
165
|
+
path: change.path,
|
|
166
|
+
message: "#{change.path} was deleted; anything that referenced it may break"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return unless change.ruby?
|
|
170
|
+
|
|
171
|
+
source = @git.show(ref: base, path: change.path)
|
|
172
|
+
return if source.nil?
|
|
173
|
+
|
|
174
|
+
Outline.parse(source).names.each { |name| add_named_node(name, change.path, nodes) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def add_declaration_node(declaration, nodes)
|
|
178
|
+
node = @graph.find_symbol(declaration.qualified_name).find { |candidate| candidate.path == declaration.path }
|
|
179
|
+
return false unless node
|
|
180
|
+
|
|
181
|
+
nodes[node.id] = node
|
|
182
|
+
true
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Looks a base-revision name up in the current graph.
|
|
186
|
+
#
|
|
187
|
+
# A deleted method no longer exists to be looked up, but its former owner
|
|
188
|
+
# usually does, and dependents of the class are what a deletion is most
|
|
189
|
+
# likely to break. Fall back to the owner rather than losing the change.
|
|
190
|
+
def add_named_node(name, _path, nodes)
|
|
191
|
+
matches = @graph.find_symbol(name)
|
|
192
|
+
matches = @graph.find_symbol(owner_of(name)) if matches.empty?
|
|
193
|
+
return false if matches.empty?
|
|
194
|
+
|
|
195
|
+
matches.each { |node| nodes[node.id] = node }
|
|
196
|
+
true
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# "BillingService#refund" -> "BillingService"
|
|
200
|
+
def owner_of(name)
|
|
201
|
+
name.to_s.split(/[#.]/).first.to_s
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def add_file_node(path, nodes)
|
|
205
|
+
node = @graph.node("file:#{path}")
|
|
206
|
+
return false unless node
|
|
207
|
+
|
|
208
|
+
nodes[node.id] = node
|
|
209
|
+
true
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# A line-to-declaration map for arbitrary source text, used for content that
|
|
213
|
+
# is not in the current tree and therefore not in the index.
|
|
214
|
+
class Outline
|
|
215
|
+
Entry = Struct.new(:name, :start_line, :end_line, keyword_init: true)
|
|
216
|
+
|
|
217
|
+
attr_reader :entries
|
|
218
|
+
|
|
219
|
+
def initialize(entries)
|
|
220
|
+
@entries = entries
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# @param source [String] Ruby source
|
|
224
|
+
# @return [Outline] empty when the source does not parse
|
|
225
|
+
def self.parse(source)
|
|
226
|
+
result = Prism.parse(source)
|
|
227
|
+
return new([]) unless result.success?
|
|
228
|
+
|
|
229
|
+
entries = []
|
|
230
|
+
walk(result.value, nil, entries)
|
|
231
|
+
new(entries)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# @return [Array<String>] every declared name
|
|
235
|
+
def names
|
|
236
|
+
entries.map(&:name)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The innermost declaration covering +line+.
|
|
240
|
+
#
|
|
241
|
+
# @return [String, nil]
|
|
242
|
+
def declaration_at(line)
|
|
243
|
+
covering = entries.select { |entry| line.between?(entry.start_line, entry.end_line) }
|
|
244
|
+
covering.min_by { |entry| entry.end_line - entry.start_line }&.name
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def self.walk(node, namespace, entries)
|
|
248
|
+
return unless node
|
|
249
|
+
|
|
250
|
+
node.compact_child_nodes.each do |child|
|
|
251
|
+
case child
|
|
252
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
253
|
+
name = qualify(namespace, constant_name(child.constant_path))
|
|
254
|
+
if name
|
|
255
|
+
entries << Entry.new(name: name, start_line: child.location.start_line,
|
|
256
|
+
end_line: child.location.end_line)
|
|
257
|
+
end
|
|
258
|
+
walk(child.body, name || namespace, entries)
|
|
259
|
+
when Prism::DefNode
|
|
260
|
+
separator = child.receiver ? "." : "#"
|
|
261
|
+
name = namespace ? "#{namespace}#{separator}#{child.name}" : child.name.to_s
|
|
262
|
+
entries << Entry.new(name: name, start_line: child.location.start_line,
|
|
263
|
+
end_line: child.location.end_line)
|
|
264
|
+
else
|
|
265
|
+
walk(child, namespace, entries)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def self.constant_name(node)
|
|
271
|
+
case node
|
|
272
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
273
|
+
when Prism::ConstantPathNode
|
|
274
|
+
parent = node.parent ? constant_name(node.parent) : nil
|
|
275
|
+
child = node.name&.to_s
|
|
276
|
+
return nil unless child
|
|
277
|
+
|
|
278
|
+
parent ? "#{parent}::#{child}" : child
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def self.qualify(namespace, name)
|
|
283
|
+
return nil if name.nil?
|
|
284
|
+
|
|
285
|
+
namespace ? "#{namespace}::#{name}" : name
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
private_class_method :walk, :constant_name, :qualify
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "../error"
|
|
5
|
+
|
|
6
|
+
module RippleEffect
|
|
7
|
+
module Diff
|
|
8
|
+
# A minimal, safe Git client.
|
|
9
|
+
#
|
|
10
|
+
# Every invocation passes an argument array to {Open3.capture3}, so a ref like
|
|
11
|
+
# `; rm -rf /` is a ref that does not exist rather than a shell command. No Git
|
|
12
|
+
# output is ever interpolated back into another command.
|
|
13
|
+
class Git
|
|
14
|
+
# One changed file in a diff.
|
|
15
|
+
#
|
|
16
|
+
# @!attribute status
|
|
17
|
+
# @return [Symbol] :added, :modified, :deleted, :renamed, or :other
|
|
18
|
+
Change = Struct.new(:status, :path, :old_path, keyword_init: true) do
|
|
19
|
+
def deleted? = status == :deleted
|
|
20
|
+
def added? = status == :added
|
|
21
|
+
def renamed? = status == :renamed
|
|
22
|
+
def ruby? = path.to_s.end_with?(".rb")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
STATUS_CODES = {
|
|
26
|
+
"A" => :added, "M" => :modified, "D" => :deleted,
|
|
27
|
+
"R" => :renamed, "C" => :added, "T" => :modified
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# @param root [String] the repository working directory
|
|
31
|
+
def initialize(root:)
|
|
32
|
+
@root = root
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Boolean] true when +root+ is inside a Git work tree
|
|
36
|
+
def repository?
|
|
37
|
+
output, status = run("rev-parse", "--is-inside-work-tree")
|
|
38
|
+
status.success? && output.strip == "true"
|
|
39
|
+
rescue GitError
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @raise [GitError] unless +root+ is a Git work tree
|
|
44
|
+
def ensure_repository!
|
|
45
|
+
return if repository?
|
|
46
|
+
|
|
47
|
+
raise GitError, "#{@root} is not a Git repository"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param ref [String]
|
|
51
|
+
# @return [Boolean] true when the ref resolves
|
|
52
|
+
def ref?(ref)
|
|
53
|
+
_, status = run("rev-parse", "--verify", "--quiet", "#{ref}^{commit}")
|
|
54
|
+
status.success?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @raise [GitError] when the ref does not resolve
|
|
58
|
+
def ensure_ref!(ref)
|
|
59
|
+
return if ref?(ref)
|
|
60
|
+
|
|
61
|
+
raise GitError, "unknown Git ref: #{ref}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Files changed between +base+ and +head+.
|
|
65
|
+
#
|
|
66
|
+
# With no +head+ the comparison is against the working tree, including both
|
|
67
|
+
# staged and unstaged changes: the state about to be committed.
|
|
68
|
+
#
|
|
69
|
+
# @param base [String] a Git ref
|
|
70
|
+
# @param head [String, nil] a Git ref, or nil for the working tree
|
|
71
|
+
# @return [Array<Change>] sorted by path
|
|
72
|
+
def changed_files(base:, head: nil)
|
|
73
|
+
ensure_repository!
|
|
74
|
+
ensure_ref!(base)
|
|
75
|
+
ensure_ref!(head) if head
|
|
76
|
+
|
|
77
|
+
args = ["diff", "--name-status", "--find-renames", "-z", base]
|
|
78
|
+
args << head if head
|
|
79
|
+
|
|
80
|
+
output, status = run(*args)
|
|
81
|
+
raise GitError, "git diff failed: #{output}" unless status.success?
|
|
82
|
+
|
|
83
|
+
changes = parse_name_status(output)
|
|
84
|
+
# `git diff` cannot see a file Git has never heard of, but a new,
|
|
85
|
+
# unstaged file is very much part of what the developer is about to commit.
|
|
86
|
+
changes += untracked_files if head.nil?
|
|
87
|
+
|
|
88
|
+
changes.uniq(&:path).sort_by(&:path)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Array<Change>] files present in the working tree but not in Git
|
|
92
|
+
def untracked_files
|
|
93
|
+
output, status = run("ls-files", "--others", "--exclude-standard", "-z")
|
|
94
|
+
return [] unless status.success?
|
|
95
|
+
|
|
96
|
+
output.split("\0").reject(&:empty?).map { |path| Change.new(status: :added, path: path, old_path: nil) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The unified diff for one file, with no context, so that every reported line
|
|
100
|
+
# is a line that actually changed.
|
|
101
|
+
#
|
|
102
|
+
# @return [String] raw diff text, empty when nothing changed
|
|
103
|
+
def file_diff(base:, path:, head: nil)
|
|
104
|
+
args = ["diff", "--unified=0", "--find-renames", base]
|
|
105
|
+
args << head if head
|
|
106
|
+
args += ["--", path]
|
|
107
|
+
|
|
108
|
+
output, status = run(*args)
|
|
109
|
+
raise GitError, "git diff failed for #{path}: #{output}" unless status.success?
|
|
110
|
+
|
|
111
|
+
output
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Reads a file's contents at a revision, for working out what a deletion removed.
|
|
115
|
+
#
|
|
116
|
+
# @return [String, nil] contents, or nil when the path did not exist there
|
|
117
|
+
def show(ref:, path:)
|
|
118
|
+
output, status = run("show", "#{ref}:#{path}")
|
|
119
|
+
status.success? ? output : nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [String] the repository's current HEAD SHA
|
|
123
|
+
def head_sha
|
|
124
|
+
output, status = run("rev-parse", "HEAD")
|
|
125
|
+
raise GitError, "could not resolve HEAD" unless status.success?
|
|
126
|
+
|
|
127
|
+
output.strip
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# `-z` output is NUL-separated, which is what makes paths containing spaces,
|
|
133
|
+
# quotes or newlines safe to parse.
|
|
134
|
+
def parse_name_status(output)
|
|
135
|
+
fields = output.split("\0")
|
|
136
|
+
changes = []
|
|
137
|
+
index = 0
|
|
138
|
+
|
|
139
|
+
while index < fields.length
|
|
140
|
+
code = fields[index]
|
|
141
|
+
index += 1
|
|
142
|
+
next if code.nil? || code.empty?
|
|
143
|
+
|
|
144
|
+
status = STATUS_CODES.fetch(code[0], :other)
|
|
145
|
+
|
|
146
|
+
if status == :renamed
|
|
147
|
+
old_path = fields[index]
|
|
148
|
+
new_path = fields[index + 1]
|
|
149
|
+
index += 2
|
|
150
|
+
next if new_path.nil?
|
|
151
|
+
|
|
152
|
+
changes << Change.new(status: :renamed, path: new_path, old_path: old_path)
|
|
153
|
+
else
|
|
154
|
+
path = fields[index]
|
|
155
|
+
index += 1
|
|
156
|
+
next if path.nil?
|
|
157
|
+
|
|
158
|
+
changes << Change.new(status: status, path: path, old_path: nil)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
changes
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def run(*)
|
|
166
|
+
stdout, stderr, status = Open3.capture3("git", *, chdir: @root)
|
|
167
|
+
[status.success? ? stdout : "#{stdout}#{stderr}", status]
|
|
168
|
+
rescue Errno::ENOENT
|
|
169
|
+
raise GitError, "git executable not found on PATH"
|
|
170
|
+
rescue SystemCallError => e
|
|
171
|
+
raise GitError, "git invocation failed: #{e.message}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RippleEffect
|
|
4
|
+
module Diff
|
|
5
|
+
# A contiguous run of changed lines within one file.
|
|
6
|
+
#
|
|
7
|
+
# Two line ranges matter and they are not the same: +new_range+ addresses the
|
|
8
|
+
# current file (where added and modified code lives), while +old_range+
|
|
9
|
+
# addresses the base revision (the only place deleted code still exists).
|
|
10
|
+
class Hunk
|
|
11
|
+
HEADER = /\A@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
|
|
12
|
+
|
|
13
|
+
attr_reader :old_start, :old_count, :new_start, :new_count
|
|
14
|
+
|
|
15
|
+
def initialize(old_start:, old_count:, new_start:, new_count:)
|
|
16
|
+
@old_start = old_start
|
|
17
|
+
@old_count = old_count
|
|
18
|
+
@new_start = new_start
|
|
19
|
+
@new_count = new_count
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Parses every hunk header out of a unified diff.
|
|
24
|
+
#
|
|
25
|
+
# @param diff [String] output of `git diff --unified=0`
|
|
26
|
+
# @return [Array<Hunk>]
|
|
27
|
+
def self.parse(diff)
|
|
28
|
+
diff.to_s.each_line.filter_map do |line|
|
|
29
|
+
match = HEADER.match(line)
|
|
30
|
+
next unless match
|
|
31
|
+
|
|
32
|
+
new(
|
|
33
|
+
old_start: match[1].to_i,
|
|
34
|
+
old_count: match[2] ? match[2].to_i : 1,
|
|
35
|
+
new_start: match[3].to_i,
|
|
36
|
+
new_count: match[4] ? match[4].to_i : 1
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Lines touched in the current revision.
|
|
42
|
+
#
|
|
43
|
+
# A zero count means a pure deletion: nothing was added at this point, so
|
|
44
|
+
# there are no lines in the new file to attribute the change to.
|
|
45
|
+
#
|
|
46
|
+
# @return [Range, nil]
|
|
47
|
+
def new_range
|
|
48
|
+
return nil if new_count.zero?
|
|
49
|
+
|
|
50
|
+
new_start..(new_start + new_count - 1)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Lines touched in the base revision.
|
|
54
|
+
#
|
|
55
|
+
# @return [Range, nil]
|
|
56
|
+
def old_range
|
|
57
|
+
return nil if old_count.zero?
|
|
58
|
+
|
|
59
|
+
old_start..(old_start + old_count - 1)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Boolean] true when this hunk only removed lines
|
|
63
|
+
def deletion_only?
|
|
64
|
+
new_count.zero? && old_count.positive?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Boolean] true when this hunk only added lines
|
|
68
|
+
def addition_only?
|
|
69
|
+
old_count.zero? && new_count.positive?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def to_h
|
|
73
|
+
{
|
|
74
|
+
"old_start" => old_start, "old_count" => old_count,
|
|
75
|
+
"new_start" => new_start, "new_count" => new_count
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "confidence"
|
|
4
|
+
|
|
5
|
+
module RippleEffect
|
|
6
|
+
# A directed, evidenced relationship between two {Node}s.
|
|
7
|
+
#
|
|
8
|
+
# The direction is always:
|
|
9
|
+
#
|
|
10
|
+
# from_id *depends on / invokes / references* into_id
|
|
11
|
+
#
|
|
12
|
+
# So walking edges backwards from a changed node finds its dependents.
|
|
13
|
+
class Edge
|
|
14
|
+
TYPES = %i[
|
|
15
|
+
constant_reference
|
|
16
|
+
method_call
|
|
17
|
+
inheritance
|
|
18
|
+
include
|
|
19
|
+
prepend
|
|
20
|
+
extend
|
|
21
|
+
association
|
|
22
|
+
callback
|
|
23
|
+
delegate
|
|
24
|
+
route_handler
|
|
25
|
+
job_enqueue
|
|
26
|
+
mailer_delivery
|
|
27
|
+
test_convention
|
|
28
|
+
file_reference
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# Human-facing phrasing for each edge type, used by the text formatter.
|
|
32
|
+
DESCRIPTIONS = {
|
|
33
|
+
constant_reference: "references constant",
|
|
34
|
+
method_call: "calls method",
|
|
35
|
+
inheritance: "inherits from",
|
|
36
|
+
include: "includes",
|
|
37
|
+
prepend: "prepends",
|
|
38
|
+
extend: "extends",
|
|
39
|
+
association: "association to",
|
|
40
|
+
callback: "callback invokes",
|
|
41
|
+
delegate: "delegates to",
|
|
42
|
+
route_handler: "route dispatches to",
|
|
43
|
+
job_enqueue: "enqueues job",
|
|
44
|
+
mailer_delivery: "delivers mail via",
|
|
45
|
+
test_convention: "conventionally tests",
|
|
46
|
+
file_reference: "references file"
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
attr_reader :from_id, :into_id, :type, :evidence, :confidence, :location, :metadata
|
|
50
|
+
|
|
51
|
+
# @param from_id [String] the dependent node's ID
|
|
52
|
+
# @param into_id [String] the depended-upon node's ID
|
|
53
|
+
# @param type [Symbol] one of {TYPES}
|
|
54
|
+
# @param evidence [String] a stable evidence code, e.g. "rails.after_commit"
|
|
55
|
+
# @param confidence [Symbol] a {Confidence} band
|
|
56
|
+
# @param location [String, nil] "path:line" where the relationship was observed
|
|
57
|
+
# @param metadata [Hash] JSON-compatible extra facts
|
|
58
|
+
def initialize(from_id:, into_id:, type:, evidence:, confidence:, location: nil, metadata: {})
|
|
59
|
+
@from_id = from_id.to_s.freeze
|
|
60
|
+
@into_id = into_id.to_s.freeze
|
|
61
|
+
@type = self.class.cast_type(type)
|
|
62
|
+
@evidence = evidence.to_s.freeze
|
|
63
|
+
@confidence = Confidence.cast(confidence)
|
|
64
|
+
@location = location&.to_s&.freeze
|
|
65
|
+
@metadata = metadata.freeze
|
|
66
|
+
freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Symbol] the canonical edge type
|
|
70
|
+
# @raise [ArgumentError] on an unknown type
|
|
71
|
+
def self.cast_type(type)
|
|
72
|
+
symbol = type.to_s.to_sym
|
|
73
|
+
return symbol if TYPES.include?(symbol)
|
|
74
|
+
|
|
75
|
+
raise ArgumentError, "unknown edge type #{type.inspect}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [String] a stable identity used for duplicate suppression
|
|
79
|
+
def key
|
|
80
|
+
"#{from_id}|#{into_id}|#{type}|#{evidence}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [String] a short human explanation, e.g. "callback invokes (rails.after_commit)"
|
|
84
|
+
def description
|
|
85
|
+
"#{DESCRIPTIONS.fetch(type, type.to_s)} (#{evidence})"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Hash] JSON-compatible representation with deterministic key order
|
|
89
|
+
def to_h
|
|
90
|
+
{
|
|
91
|
+
"from_id" => from_id,
|
|
92
|
+
"into_id" => into_id,
|
|
93
|
+
"type" => type.to_s,
|
|
94
|
+
"evidence" => evidence,
|
|
95
|
+
"confidence" => confidence.to_s,
|
|
96
|
+
"location" => location,
|
|
97
|
+
"metadata" => metadata.to_h { |k, v| [k.to_s, v.is_a?(Symbol) ? v.to_s : v] }
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def ==(other)
|
|
102
|
+
other.is_a?(Edge) && other.key == key
|
|
103
|
+
end
|
|
104
|
+
alias eql? ==
|
|
105
|
+
|
|
106
|
+
def hash
|
|
107
|
+
key.hash
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def inspect
|
|
111
|
+
"#<RippleEffect::Edge #{from_id} -#{type}-> #{into_id} (#{confidence})>"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RippleEffect
|
|
4
|
+
# Base class for every error raised by Ripple Effect.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when `.ripple-effect.yml` is missing required keys or holds invalid values.
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when the project root is unusable (missing, not a directory, unreadable).
|
|
11
|
+
class ProjectError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when the static index cannot be initialised at all.
|
|
14
|
+
#
|
|
15
|
+
# A failure to index a *single* file is a diagnostic, not an error.
|
|
16
|
+
class IndexError < Error; end
|
|
17
|
+
|
|
18
|
+
# Raised when a user-supplied symbol query cannot be resolved to exactly one declaration.
|
|
19
|
+
class QueryError < Error; end
|
|
20
|
+
|
|
21
|
+
# Raised when a Git invocation fails or the project is not a Git work tree.
|
|
22
|
+
class GitError < Error; end
|
|
23
|
+
end
|