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,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RippleEffect
4
+ module Formatters
5
+ # Graphviz DOT output, for piping into `dot -Tsvg`.
6
+ #
7
+ # Only the impacted subgraph is emitted, not the whole project: a 5,000-file
8
+ # application's full graph is not something anyone can read.
9
+ class Dot
10
+ SHAPES = {
11
+ route: "house", job: "component", callback: "diamond", mailer_action: "note",
12
+ test_file: "folder", file: "folder", class: "box", module: "box3d"
13
+ }.freeze
14
+
15
+ STYLES = { high: "solid", medium: "dashed", low: "dotted" }.freeze
16
+
17
+ # @param result [Result]
18
+ def initialize(result:)
19
+ @result = result
20
+ end
21
+
22
+ # @return [String]
23
+ def render
24
+ lines = ["digraph ripple_effect {", " rankdir=RL;", " node [fontname=\"Helvetica\", fontsize=10];",
25
+ " edge [fontname=\"Helvetica\", fontsize=8];"]
26
+
27
+ @result.changed_nodes.each do |node|
28
+ lines << " #{quote(node.id)} [label=#{quote(node.name)}, shape=#{shape(node)}, " \
29
+ "style=filled, fillcolor=\"#ffe0e0\"];"
30
+ end
31
+
32
+ @result.impacts.each do |impact|
33
+ node = impact.node
34
+ lines << " #{quote(node.id)} [label=#{quote(node.name)}, shape=#{shape(node)}];"
35
+ end
36
+
37
+ edges.each do |edge|
38
+ lines << " #{quote(edge.from_id)} -> #{quote(edge.into_id)} " \
39
+ "[label=#{quote(edge.type.to_s)}, style=#{STYLES.fetch(edge.confidence, 'solid')}];"
40
+ end
41
+
42
+ lines << "}"
43
+ "#{lines.join("\n")}\n"
44
+ end
45
+
46
+ private
47
+
48
+ # Every edge that appears in some impact's evidence path, deduplicated.
49
+ def edges
50
+ seen = {}
51
+
52
+ @result.impacts.flat_map(&:evidence_path).each_with_object([]) do |edge, result|
53
+ next if seen[edge.key]
54
+
55
+ seen[edge.key] = true
56
+ result << edge
57
+ end
58
+ end
59
+
60
+ def shape(node)
61
+ SHAPES.fetch(node.kind, "ellipse")
62
+ end
63
+
64
+ def quote(text)
65
+ "\"#{text.to_s.gsub('\\', '\\\\\\\\').gsub('"', '\\"')}\""
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RippleEffect
6
+ module Formatters
7
+ # Machine-readable output.
8
+ #
9
+ # This is a public interface with a schema version, so it is generated from
10
+ # {Result#to_h} without reordering or filtering: what the library exposes and
11
+ # what the CLI prints are the same document.
12
+ class Json
13
+ # @param result [Result]
14
+ # @param pretty [Boolean]
15
+ def initialize(result:, pretty: true)
16
+ @result = result
17
+ @pretty = pretty
18
+ end
19
+
20
+ # @return [String]
21
+ def render
22
+ payload = @result.to_h
23
+ "#{@pretty ? JSON.pretty_generate(payload) : JSON.generate(payload)}\n"
24
+ end
25
+
26
+ # The error envelope. Errors in JSON mode are still JSON, so a consumer
27
+ # never has to parse a stack trace out of stderr.
28
+ #
29
+ # @param error [Exception]
30
+ # @param code [String] a stable error code
31
+ # @return [String]
32
+ def self.error(error, code: "error")
33
+ payload = JSON.pretty_generate(
34
+ "schema_version" => SCHEMA_VERSION,
35
+ "tool" => { "name" => "ripple_effect", "version" => VERSION },
36
+ "error" => { "code" => code, "type" => error.class.name, "message" => error.message }
37
+ )
38
+
39
+ "#{payload}\n"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RippleEffect
4
+ module Formatters
5
+ # Human-readable output.
6
+ #
7
+ # Impacts are grouped by the kind of evidence that reached them, because "this
8
+ # is a callback" and "this is a method call" are different claims and deserve
9
+ # to be read differently.
10
+ class Text
11
+ SECTIONS = [
12
+ ["Direct dependents", ->(impact) { impact.depth == 1 && code_edge?(impact) }],
13
+ ["Rails semantic dependents", ->(impact) { rails_edge?(impact) }],
14
+ ["Transitive dependents", ->(impact) { impact.depth > 1 && code_edge?(impact) }]
15
+ ].freeze
16
+
17
+ # Edges that hold the graph together: a file "depending on" the classes it
18
+ # declares, a class on the methods it defines. Needed for traversal and for
19
+ # mapping a diff onto symbols, but reporting them as dependents buries the
20
+ # real answer. JSON output keeps them.
21
+ STRUCTURAL_EVIDENCE = %w[ruby.file_declares ruby.defines_method].freeze
22
+
23
+ # @param result [Result]
24
+ # @param verbose [Boolean] include stats and every diagnostic
25
+ def initialize(result:, verbose: false)
26
+ @result = result
27
+ @verbose = verbose
28
+ end
29
+
30
+ # @return [String]
31
+ def render
32
+ lines = []
33
+ lines.concat(header)
34
+ lines.concat(impact_sections)
35
+ lines.concat(tests_section)
36
+ lines.concat(blast_radius_section)
37
+ lines.concat(diagnostics_section)
38
+ lines.concat(stats_section) if @verbose
39
+ "#{lines.join("\n").rstrip}\n"
40
+ end
41
+
42
+ class << self
43
+ # A callback, route, job, mailer, association or delegation, as opposed
44
+ # to a plain Ruby reference.
45
+ def rails_edge?(impact)
46
+ edge = impact.reason
47
+ return false unless edge
48
+
49
+ %i[callback route_handler job_enqueue mailer_delivery association delegate].include?(edge.type)
50
+ end
51
+
52
+ def code_edge?(impact)
53
+ return false if rails_edge?(impact)
54
+ return false if impact.node.kind == :test_file
55
+ return false if structural?(impact)
56
+
57
+ true
58
+ end
59
+
60
+ # @return [Boolean] true when this impact exists only because of graph
61
+ # containment, not because of a real reference
62
+ def structural?(impact)
63
+ edge = impact.reason
64
+ return false unless edge
65
+
66
+ STRUCTURAL_EVIDENCE.include?(edge.evidence)
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def header
73
+ lines = []
74
+
75
+ if @result.query_type == :diff
76
+ lines << "#{@result.changed_nodes.length} changed #{plural(@result.changed_nodes.length, 'symbol')} " \
77
+ "detected in #{@result.query_value}"
78
+ lines << ""
79
+ @result.changed_nodes.each { |node| lines << " #{node.name} (#{node.location})" }
80
+ else
81
+ lines << @result.query_value
82
+ changed = @result.changed_nodes.first
83
+ lines << " #{changed.location}" if changed
84
+ end
85
+
86
+ lines << ""
87
+ lines
88
+ end
89
+
90
+ def impact_sections
91
+ lines = []
92
+ rendered = {}
93
+
94
+ SECTIONS.each do |title, matcher|
95
+ matching = @result.impacts.reject { |impact| rendered[impact.node.id] }
96
+ .select { |impact| matcher.call(impact) }
97
+ next if matching.empty?
98
+
99
+ lines << title
100
+ matching.each do |impact|
101
+ rendered[impact.node.id] = true
102
+ lines.concat(impact_lines(impact))
103
+ end
104
+ lines << ""
105
+ end
106
+
107
+ if lines.empty?
108
+ lines << "No dependents found at the current confidence threshold."
109
+ lines << ""
110
+ end
111
+
112
+ lines
113
+ end
114
+
115
+ def impact_lines(impact)
116
+ edge = impact.reason
117
+ location = impact.node.location.ljust(42)
118
+ # A file or template node's name is its path, which would just repeat the
119
+ # location; label it by what it is instead.
120
+ label = impact.node.name == impact.node.path ? kind_label(impact.node) : impact.node.name
121
+
122
+ [
123
+ " #{location} #{label}",
124
+ " because: #{edge ? edge.description : 'reachable'}#{at(edge)}",
125
+ " confidence: #{impact.confidence}, depth: #{impact.depth}"
126
+ ]
127
+ end
128
+
129
+ def kind_label(node)
130
+ case node.kind
131
+ when :view then node.metadata["partial"] ? "(partial)" : "(template)"
132
+ when :test_file then "(test)"
133
+ else "(file)"
134
+ end
135
+ end
136
+
137
+ def at(edge)
138
+ edge&.location ? " at #{edge.location}" : ""
139
+ end
140
+
141
+ def tests_section
142
+ return [] if @result.tests.empty?
143
+
144
+ lines = ["Likely affected tests"]
145
+ @result.tests.each do |test|
146
+ lines << " #{test.path.ljust(46)} #{test.reason} (#{test.confidence})"
147
+ end
148
+ lines << ""
149
+ lines
150
+ end
151
+
152
+ def blast_radius_section
153
+ risk = @result.risk
154
+
155
+ lines = [
156
+ "Blast radius",
157
+ " direct nodes: #{@result.direct_count}",
158
+ " transitive nodes: #{@result.transitive_count}",
159
+ " max depth: #{@result.max_depth}",
160
+ " risk: #{risk.level}"
161
+ ]
162
+
163
+ unless risk.reasons.empty?
164
+ lines << " because:"
165
+ risk.reasons.each { |reason| lines << " #{reason}" }
166
+ end
167
+
168
+ lines << ""
169
+ lines
170
+ end
171
+
172
+ def diagnostics_section
173
+ shown = @verbose ? @result.diagnostics : @result.diagnostics.select { |d| d.severity == :warning }
174
+ return [] if shown.empty?
175
+
176
+ lines = ["Diagnostics"]
177
+ shown.first(@verbose ? shown.length : 10).each { |diagnostic| lines << " #{diagnostic}" }
178
+
179
+ hidden = shown.length - 10
180
+ lines << " (#{hidden} more; re-run with --verbose)" if !@verbose && hidden.positive?
181
+ lines << ""
182
+ lines
183
+ end
184
+
185
+ def stats_section
186
+ lines = ["Stats"]
187
+ @result.stats.each { |key, value| lines << " #{key}: #{value}" }
188
+ lines << ""
189
+ lines
190
+ end
191
+
192
+ def plural(count, word)
193
+ count == 1 ? word : "#{word}s"
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "node"
4
+ require_relative "edge"
5
+ require_relative "confidence"
6
+
7
+ module RippleEffect
8
+ # The dependency graph: nodes plus evidenced, directed edges, with forward and
9
+ # reverse adjacency maintained as edges are added.
10
+ #
11
+ # Edges point from dependent to dependency, so {#dependents} walks the reverse
12
+ # index and answers "what could break if this changes?".
13
+ #
14
+ # Iteration order is insertion order, and every traversal sorts its frontier by
15
+ # node ID, so identical input always produces identical output.
16
+ class Graph
17
+ def initialize
18
+ @nodes = {}
19
+ @outgoing = Hash.new { |hash, key| hash[key] = [] }
20
+ @incoming = Hash.new { |hash, key| hash[key] = [] }
21
+ @edge_keys = {}
22
+ @by_path = Hash.new { |hash, key| hash[key] = [] }
23
+ @by_name = Hash.new { |hash, key| hash[key] = [] }
24
+ end
25
+
26
+ # Adds a node, or returns the existing node when the ID is already present.
27
+ #
28
+ # @param node [Node]
29
+ # @return [Node] the node stored under this ID
30
+ def add_node(node)
31
+ existing = @nodes[node.id]
32
+ return existing if existing
33
+
34
+ @nodes[node.id] = node
35
+ @by_path[node.path] << node
36
+ @by_name[node.qualified_name] << node if node.qualified_name
37
+ @by_name[node.name] << node if node.name != node.qualified_name
38
+ node
39
+ end
40
+
41
+ # Adds an edge unless an identical one (same endpoints, type and evidence)
42
+ # already exists.
43
+ #
44
+ # @param edge [Edge]
45
+ # @return [Boolean] true when the edge was newly added
46
+ def add_edge(edge)
47
+ return false if @edge_keys.key?(edge.key)
48
+
49
+ @edge_keys[edge.key] = edge
50
+ @outgoing[edge.from_id] << edge
51
+ @incoming[edge.into_id] << edge
52
+ true
53
+ end
54
+
55
+ # @return [Node, nil]
56
+ def node(id)
57
+ @nodes[id]
58
+ end
59
+
60
+ # @return [Boolean]
61
+ def node?(id)
62
+ @nodes.key?(id)
63
+ end
64
+
65
+ # @return [Array<Node>]
66
+ def nodes
67
+ @nodes.values
68
+ end
69
+
70
+ # @return [Array<Edge>]
71
+ def edges
72
+ @edge_keys.values
73
+ end
74
+
75
+ def node_count = @nodes.size
76
+ def edge_count = @edge_keys.size
77
+
78
+ # Edges where +id+ is the dependent.
79
+ # @return [Array<Edge>]
80
+ def outgoing(id)
81
+ @outgoing.key?(id) ? @outgoing[id] : []
82
+ end
83
+
84
+ # Edges where +id+ is the dependency.
85
+ # @return [Array<Edge>]
86
+ def incoming(id)
87
+ @incoming.key?(id) ? @incoming[id] : []
88
+ end
89
+
90
+ # @return [Array<Node>] every node declared in +path+
91
+ def nodes_by_path(path)
92
+ @by_path.key?(path) ? @by_path[path] : []
93
+ end
94
+
95
+ # @return [Array<String>] every indexed path, sorted
96
+ def paths
97
+ @by_path.keys.sort
98
+ end
99
+
100
+ # Nodes that depend on +id+, directly or transitively.
101
+ #
102
+ # @param id [String]
103
+ # @param depth [Integer, nil] maximum hops, nil for unlimited
104
+ # @param min_confidence [Symbol] skip edges weaker than this band
105
+ # @return [Array<Node>] sorted by ID
106
+ def dependents(id, depth: nil, min_confidence: Confidence::LOW)
107
+ walk(id, direction: :incoming, depth: depth, min_confidence: min_confidence)
108
+ end
109
+
110
+ # Nodes that +id+ depends on, directly or transitively.
111
+ #
112
+ # @see #dependents
113
+ # @return [Array<Node>] sorted by ID
114
+ def dependencies(id, depth: nil, min_confidence: Confidence::LOW)
115
+ walk(id, direction: :outgoing, depth: depth, min_confidence: min_confidence)
116
+ end
117
+
118
+ # Finds nodes whose qualified name or display name matches +query+ exactly.
119
+ #
120
+ # Accepts both +User.find+ and +User::find+ for singleton methods; the returned
121
+ # nodes always use the canonical +User.find+ form.
122
+ #
123
+ # @param query [String]
124
+ # @return [Array<Node>] sorted by ID; empty when nothing matches
125
+ def find_symbol(query)
126
+ needle = normalize_query(query)
127
+ return [] unless @by_name.key?(needle)
128
+
129
+ @by_name[needle].uniq.sort_by(&:id)
130
+ end
131
+
132
+ # @return [Array<Node>] every node whose path matches, for file-shaped queries
133
+ def find_path(query)
134
+ nodes_by_path(query).sort_by(&:id)
135
+ end
136
+
137
+ # Normalises a user query into the canonical display form.
138
+ #
139
+ # @param query [String]
140
+ # @return [String]
141
+ def normalize_query(query)
142
+ text = query.to_s.strip
143
+ # `User::find` is a legal way to write a singleton call, but the constant
144
+ # separator is ambiguous: only rewrite when the last segment starts lowercase.
145
+ if (match = text.match(/\A(.+)::([a-z_][A-Za-z0-9_]*[?!=]?)\z/))
146
+ return "#{match[1]}.#{match[2]}"
147
+ end
148
+
149
+ text
150
+ end
151
+
152
+ # @return [Hash<Symbol, Integer>] node counts per kind, for `doctor` and stats
153
+ def counts_by_kind
154
+ nodes.each_with_object(Hash.new(0)) { |node, counts| counts[node.kind] += 1 }
155
+ end
156
+
157
+ private
158
+
159
+ # Breadth-first walk that returns reached nodes only. {Traversal::ImpactWalker}
160
+ # does the same walk while retaining evidence paths.
161
+ def walk(id, direction:, depth:, min_confidence:)
162
+ return [] unless node?(id)
163
+
164
+ visited = { id => true }
165
+ frontier = [id]
166
+ reached = []
167
+ hops = 0
168
+
169
+ while !frontier.empty? && (depth.nil? || hops < depth)
170
+ hops += 1
171
+ next_frontier = []
172
+
173
+ frontier.sort.each do |current|
174
+ edges_for(current, direction).each do |edge|
175
+ next unless Confidence.at_least?(edge.confidence, min_confidence)
176
+
177
+ neighbour = direction == :incoming ? edge.from_id : edge.into_id
178
+ next if visited[neighbour]
179
+
180
+ visited[neighbour] = true
181
+ found = @nodes[neighbour]
182
+ next unless found
183
+
184
+ reached << found
185
+ next_frontier << neighbour
186
+ end
187
+ end
188
+
189
+ frontier = next_frontier
190
+ end
191
+
192
+ reached.sort_by(&:id)
193
+ end
194
+
195
+ def edges_for(id, direction)
196
+ direction == :incoming ? incoming(id) : outgoing(id)
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RippleEffect
4
+ # A single addressable thing in the analysed project: a file, a class, a method,
5
+ # a route, a callback, and so on.
6
+ #
7
+ # Nodes are value objects with deterministic string IDs. Two nodes with the same
8
+ # +id+ are considered equal, which is what lets the graph suppress duplicates that
9
+ # several extractors independently discover.
10
+ class Node
11
+ KINDS = %i[
12
+ file
13
+ class
14
+ module
15
+ instance_method
16
+ class_method
17
+ route
18
+ test_file
19
+ callback
20
+ association
21
+ job
22
+ mailer_action
23
+ view
24
+ unknown
25
+ ].freeze
26
+
27
+ attr_reader :id, :kind, :name, :qualified_name, :path, :start_line, :end_line, :metadata
28
+
29
+ # @param kind [Symbol] one of {KINDS}
30
+ # @param name [String] display name
31
+ # @param path [String] project-relative path
32
+ # @param qualified_name [String, nil] canonical symbol name, when the kind has one
33
+ # @param start_line [Integer, nil]
34
+ # @param end_line [Integer, nil]
35
+ # @param metadata [Hash] JSON-compatible extra facts
36
+ # @param id [String, nil] override the derived ID; normally left nil
37
+ def initialize(kind:, name:, path:, qualified_name: nil, start_line: nil, end_line: nil, metadata: {}, id: nil)
38
+ @kind = self.class.cast_kind(kind)
39
+ @name = name.to_s
40
+ @path = path.to_s
41
+ @qualified_name = qualified_name&.to_s
42
+ @start_line = start_line
43
+ @end_line = end_line
44
+ @metadata = deep_freeze(metadata)
45
+ @id = (id || derive_id).freeze
46
+ freeze
47
+ end
48
+
49
+ # @return [Symbol] the canonical kind
50
+ # @raise [ArgumentError] on an unknown kind
51
+ def self.cast_kind(kind)
52
+ symbol = kind.to_s.to_sym
53
+ return symbol if KINDS.include?(symbol)
54
+
55
+ raise ArgumentError, "unknown node kind #{kind.inspect}"
56
+ end
57
+
58
+ # @return [Boolean] true for instance and singleton methods
59
+ def method?
60
+ %i[instance_method class_method].include?(kind)
61
+ end
62
+
63
+ # @return [Boolean] true for classes and modules
64
+ def namespace?
65
+ %i[class module].include?(kind)
66
+ end
67
+
68
+ # @return [String] "path:line" for humans, or just the path when no line is known
69
+ def location
70
+ start_line ? "#{path}:#{start_line}" : path
71
+ end
72
+
73
+ # @return [Boolean] true when +line+ falls inside this node's line range
74
+ def covers_line?(line)
75
+ return false if start_line.nil?
76
+
77
+ line.between?(start_line, end_line || start_line)
78
+ end
79
+
80
+ # @return [Integer, nil] number of source lines spanned, nil when unknown
81
+ def span
82
+ return nil if start_line.nil?
83
+
84
+ (end_line || start_line) - start_line + 1
85
+ end
86
+
87
+ # @return [Hash] JSON-compatible representation with deterministic key order
88
+ def to_h
89
+ {
90
+ "id" => id,
91
+ "kind" => kind.to_s,
92
+ "name" => name,
93
+ "qualified_name" => qualified_name,
94
+ "path" => path,
95
+ "start_line" => start_line,
96
+ "end_line" => end_line,
97
+ "metadata" => stringify(metadata)
98
+ }
99
+ end
100
+
101
+ def ==(other)
102
+ other.is_a?(Node) && other.id == id
103
+ end
104
+ alias eql? ==
105
+
106
+ def hash
107
+ id.hash
108
+ end
109
+
110
+ def to_s
111
+ "#{kind}:#{name}"
112
+ end
113
+
114
+ def inspect
115
+ "#<RippleEffect::Node #{id}>"
116
+ end
117
+
118
+ private
119
+
120
+ # Deterministic, human-readable IDs. Location is included for method and
121
+ # namespace nodes so that a class reopened in two files does not collide.
122
+ def derive_id
123
+ case kind
124
+ when :instance_method, :class_method
125
+ "method:#{path}:#{qualified_name || name}"
126
+ when :class, :module
127
+ "#{kind}:#{path}:#{qualified_name || name}"
128
+ when :file, :test_file
129
+ "file:#{path}"
130
+ else
131
+ "#{kind}:#{path}:#{start_line || 0}:#{qualified_name || name}"
132
+ end
133
+ end
134
+
135
+ def stringify(value)
136
+ case value
137
+ when Hash then value.to_h { |k, v| [k.to_s, stringify(v)] }
138
+ when Array then value.map { |v| stringify(v) }
139
+ when Symbol then value.to_s
140
+ else value
141
+ end
142
+ end
143
+
144
+ def deep_freeze(value)
145
+ case value
146
+ when Hash then value.transform_values { |v| deep_freeze(v) }.freeze
147
+ when Array then value.map { |v| deep_freeze(v) }.freeze
148
+ when String then value.frozen? ? value : value.dup.freeze
149
+ else value
150
+ end
151
+ end
152
+ end
153
+ end