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