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