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,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require_relative "error"
|
|
5
|
+
require_relative "configuration"
|
|
6
|
+
|
|
7
|
+
module RippleEffect
|
|
8
|
+
# The analysed repository: its root, its configuration, and the set of source
|
|
9
|
+
# files that configuration selects.
|
|
10
|
+
#
|
|
11
|
+
# Every path Ripple Effect reports is project-relative, and every path it reads is
|
|
12
|
+
# checked to be inside the root, so a stray symlink or a `../` in a config glob
|
|
13
|
+
# cannot pull in files from elsewhere on the machine.
|
|
14
|
+
class Project
|
|
15
|
+
# Directories that make a repository look like a Rails application. Used by
|
|
16
|
+
# `doctor` for advice only; analysis never requires them.
|
|
17
|
+
RAILS_MARKERS = ["app", "config", "config/routes.rb", "config/application.rb"].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :root, :configuration
|
|
20
|
+
|
|
21
|
+
# @param root [String] project root, absolute or relative
|
|
22
|
+
# @param configuration [Configuration, nil] loaded from the root when omitted
|
|
23
|
+
# @param config_path [String, nil] explicit config file path
|
|
24
|
+
# @raise [ProjectError] when the root does not exist or is not a directory
|
|
25
|
+
def initialize(root:, configuration: nil, config_path: nil)
|
|
26
|
+
expanded = File.expand_path(root.to_s)
|
|
27
|
+
raise ProjectError, "project root does not exist: #{expanded}" unless File.exist?(expanded)
|
|
28
|
+
raise ProjectError, "project root is not a directory: #{expanded}" unless File.directory?(expanded)
|
|
29
|
+
|
|
30
|
+
# Anchor on the canonical path, not on what the user typed.
|
|
31
|
+
#
|
|
32
|
+
# macOS and Windows filesystems are case-insensitive but case-preserving.
|
|
33
|
+
# `--root ~/code/appease` finds files in `~/code/AppEase`, and Dir.glob
|
|
34
|
+
# returns them with their real casing. Comparing those against the typed
|
|
35
|
+
# root then fails for every file and the analysis comes back empty with no
|
|
36
|
+
# error. Resolving symlinks here covers a symlinked root too.
|
|
37
|
+
@root = canonical_root(expanded).freeze
|
|
38
|
+
@root_pathname = Pathname.new(@root)
|
|
39
|
+
@configuration = configuration || Configuration.load(root: @root, path: config_path)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Absolute paths of every file matching the include globs and surviving the
|
|
43
|
+
# exclude globs, sorted so that indexing order is deterministic.
|
|
44
|
+
#
|
|
45
|
+
# @return [Array<String>]
|
|
46
|
+
def source_files
|
|
47
|
+
@source_files ||= glob_all(configuration.include_patterns).sort
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Directories that are themselves Rails engines or gems, each with their own
|
|
51
|
+
# `app/` and `lib/`.
|
|
52
|
+
#
|
|
53
|
+
# Solidus and Spree are built this way, and plenty of applications keep an
|
|
54
|
+
# `engines/` directory. Such repos have no top-level `app/`, so the default
|
|
55
|
+
# patterns would match nothing. A directory qualifies only if it has an `app/`
|
|
56
|
+
# and declares itself a gem or engine, which keeps ordinary subdirectories out.
|
|
57
|
+
#
|
|
58
|
+
# @return [Array<String>] project-relative directory paths, sorted
|
|
59
|
+
def engine_roots
|
|
60
|
+
@engine_roots ||= begin
|
|
61
|
+
candidates = Dir.glob(File.join(root, "*/"), File::FNM_DOTMATCH) +
|
|
62
|
+
Dir.glob(File.join(root, "*/*/"), File::FNM_DOTMATCH)
|
|
63
|
+
|
|
64
|
+
candidates.filter_map { |dir| engine_root_for(dir) }.uniq.sort
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Array<String>] project-relative source paths, sorted
|
|
69
|
+
def source_paths
|
|
70
|
+
@source_paths ||= source_files.map { |path| relative_path(path) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Template files matching the configured view patterns.
|
|
74
|
+
#
|
|
75
|
+
# @return [Array<String>] project-relative paths, sorted
|
|
76
|
+
def view_paths
|
|
77
|
+
@view_paths ||= glob_all(configuration.view_patterns)
|
|
78
|
+
.map { |path| relative_path(path) }
|
|
79
|
+
.reject { |path| path.start_with?("/") }
|
|
80
|
+
.sort
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Array<String>] project-relative test paths, sorted
|
|
84
|
+
def test_paths
|
|
85
|
+
@test_paths ||= source_paths.select { |path| test_path?(path) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Membership lookup for indexed paths.
|
|
89
|
+
#
|
|
90
|
+
# Extractors ask this once per candidate path, so a linear scan over
|
|
91
|
+
# {#source_paths} would be quadratic on a large application.
|
|
92
|
+
#
|
|
93
|
+
# @param path [String] project-relative path
|
|
94
|
+
# @return [Boolean]
|
|
95
|
+
def indexed?(path)
|
|
96
|
+
@indexed_paths ||= source_paths.to_set
|
|
97
|
+
|
|
98
|
+
@indexed_paths.include?(path)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @param path [String] project-relative path
|
|
102
|
+
# @return [Boolean] true for files under spec/ or test/
|
|
103
|
+
def test_path?(path)
|
|
104
|
+
path.start_with?("spec/", "test/")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Converts an absolute path (or a file:// URI) into a project-relative path.
|
|
108
|
+
#
|
|
109
|
+
# @param path [String]
|
|
110
|
+
# @return [String] the relative path, or the input unchanged when outside the root
|
|
111
|
+
def relative_path(path)
|
|
112
|
+
absolute = normalize(path)
|
|
113
|
+
return absolute unless absolute.start_with?("#{root}/")
|
|
114
|
+
|
|
115
|
+
absolute[(root.length + 1)..]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @param path [String] project-relative path
|
|
119
|
+
# @return [String] absolute path
|
|
120
|
+
def absolute_path(path)
|
|
121
|
+
File.expand_path(path, root)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Reads a project file, refusing anything that resolves outside the root.
|
|
125
|
+
#
|
|
126
|
+
# @param path [String] project-relative path
|
|
127
|
+
# @return [String, nil] file contents, or nil when the file is missing
|
|
128
|
+
# @raise [ProjectError] when the path escapes the project root
|
|
129
|
+
def read(path)
|
|
130
|
+
absolute = absolute_path(path)
|
|
131
|
+
raise ProjectError, "refusing to read outside the project root: #{path}" unless inside_root?(absolute)
|
|
132
|
+
return nil unless File.file?(absolute)
|
|
133
|
+
|
|
134
|
+
File.read(absolute, encoding: Encoding::UTF_8)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Whether a path resolves to somewhere inside the project.
|
|
138
|
+
#
|
|
139
|
+
# Symlinks are resolved before the comparison, so a link that sits inside the
|
|
140
|
+
# project but points outside it is rejected. A lexical check alone would let
|
|
141
|
+
# such a link through, which is the case this guard exists for.
|
|
142
|
+
#
|
|
143
|
+
# @return [Boolean]
|
|
144
|
+
def inside_root?(path)
|
|
145
|
+
absolute = normalize(path)
|
|
146
|
+
return true if absolute == root || absolute == real_root
|
|
147
|
+
|
|
148
|
+
resolved = resolve_symlinks(absolute)
|
|
149
|
+
return false if resolved.nil?
|
|
150
|
+
|
|
151
|
+
resolved.start_with?("#{real_root}/")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @return [Boolean] true when the root looks like a Rails application
|
|
155
|
+
def rails_like?
|
|
156
|
+
RAILS_MARKERS.any? { |marker| File.exist?(File.join(root, marker)) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Detects the test framework in use, honouring an explicit config setting.
|
|
160
|
+
#
|
|
161
|
+
# @return [Symbol] :rspec, :minitest, or :unknown
|
|
162
|
+
def test_framework
|
|
163
|
+
configured = configuration.test_framework
|
|
164
|
+
return configured unless configured == :auto
|
|
165
|
+
|
|
166
|
+
return :rspec if File.directory?(File.join(root, "spec"))
|
|
167
|
+
return :minitest if File.directory?(File.join(root, "test"))
|
|
168
|
+
|
|
169
|
+
:unknown
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# @param path [String] project-relative path
|
|
173
|
+
# @return [Boolean] true when the path matches a configured global/boot-impact glob
|
|
174
|
+
def global_file?(path)
|
|
175
|
+
configuration.unsafe_global_files.any? do |pattern|
|
|
176
|
+
File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
|
|
177
|
+
File.fnmatch?(pattern, path, File::FNM_EXTGLOB)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @return [String] absolute path of the cache directory
|
|
182
|
+
def cache_directory
|
|
183
|
+
absolute_path(configuration.cache_directory)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
# Expands patterns against the project root and, when engine discovery is on,
|
|
189
|
+
# against each discovered engine root as well.
|
|
190
|
+
def glob_all(patterns)
|
|
191
|
+
roots = [root]
|
|
192
|
+
roots += engine_roots.map { |dir| File.join(root, dir) } if configuration.discover_engines
|
|
193
|
+
|
|
194
|
+
matched = roots.flat_map do |base|
|
|
195
|
+
patterns.flat_map { |pattern| Dir.glob(File.join(base, pattern), File::FNM_DOTMATCH) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
matched.uniq
|
|
199
|
+
.select { |path| File.file?(path) }
|
|
200
|
+
.reject { |path| excluded?(path) }
|
|
201
|
+
.select { |path| inside_root?(path) }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# @return [String, nil] project-relative path when +dir+ is an engine root
|
|
205
|
+
def engine_root_for(dir)
|
|
206
|
+
absolute = dir.chomp("/")
|
|
207
|
+
relative = relative_path(absolute)
|
|
208
|
+
return nil if relative.start_with?("/") || relative.empty?
|
|
209
|
+
return nil if excluded?(absolute)
|
|
210
|
+
return nil unless File.directory?(File.join(absolute, "app"))
|
|
211
|
+
|
|
212
|
+
# A gemspec, or a Rails engine class, is what separates an engine from a
|
|
213
|
+
# directory that merely happens to contain something called `app`.
|
|
214
|
+
gem_like = !Dir.glob(File.join(absolute, "*.gemspec")).empty? ||
|
|
215
|
+
!Dir.glob(File.join(absolute, "lib/**/engine.rb")).empty?
|
|
216
|
+
|
|
217
|
+
gem_like ? relative : nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @return [String] the filesystem's own spelling of this directory
|
|
221
|
+
def canonical_root(expanded)
|
|
222
|
+
File.realpath(expanded)
|
|
223
|
+
rescue SystemCallError
|
|
224
|
+
expanded
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def real_root
|
|
228
|
+
@real_root ||= canonical_root(root)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Resolves symlinks as far as the filesystem allows.
|
|
232
|
+
#
|
|
233
|
+
# A path that does not exist yet cannot be resolved, so resolve its nearest
|
|
234
|
+
# existing ancestor and re-append the remainder. Otherwise writing a new cache
|
|
235
|
+
# file would look like an escape attempt.
|
|
236
|
+
#
|
|
237
|
+
# @return [String, nil] nil when not even the root of the path exists
|
|
238
|
+
def resolve_symlinks(absolute)
|
|
239
|
+
File.realpath(absolute)
|
|
240
|
+
rescue SystemCallError
|
|
241
|
+
parent = File.dirname(absolute)
|
|
242
|
+
return nil if parent == absolute
|
|
243
|
+
|
|
244
|
+
resolved_parent = resolve_symlinks(parent)
|
|
245
|
+
resolved_parent && File.join(resolved_parent, File.basename(absolute))
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def normalize(path)
|
|
249
|
+
text = path.to_s
|
|
250
|
+
text = text.delete_prefix("file://") if text.start_with?("file://")
|
|
251
|
+
File.expand_path(text, root)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def excluded?(absolute)
|
|
255
|
+
relative = relative_path(absolute)
|
|
256
|
+
|
|
257
|
+
configuration.exclude_patterns.any? do |pattern|
|
|
258
|
+
File.fnmatch?(pattern, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
|
|
259
|
+
File.fnmatch?(pattern, relative, File::FNM_EXTGLOB) ||
|
|
260
|
+
relative.start_with?("#{pattern.sub(%r{/\*\*\z}, '')}/")
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "version"
|
|
4
|
+
|
|
5
|
+
module RippleEffect
|
|
6
|
+
# A ranked test file, with the evidence that made it relevant.
|
|
7
|
+
class TestSuggestion
|
|
8
|
+
# Strong evidence first. A test that references the changed code directly beats
|
|
9
|
+
# one that merely sits at the conventional path.
|
|
10
|
+
RANKS = {
|
|
11
|
+
"reference" => 0,
|
|
12
|
+
"rails_semantic" => 1,
|
|
13
|
+
"convention" => 2,
|
|
14
|
+
"heuristic" => 3
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :path, :reason, :confidence, :depth, :evidence
|
|
18
|
+
|
|
19
|
+
def initialize(path:, reason:, confidence:, depth:, evidence:)
|
|
20
|
+
@path = path
|
|
21
|
+
@reason = reason
|
|
22
|
+
@confidence = confidence
|
|
23
|
+
@depth = depth
|
|
24
|
+
@evidence = evidence
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Integer] sort key; lower is more relevant
|
|
29
|
+
def rank
|
|
30
|
+
RANKS.fetch(reason, RANKS.size)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
{
|
|
35
|
+
"path" => path,
|
|
36
|
+
"reason" => reason,
|
|
37
|
+
"evidence" => evidence,
|
|
38
|
+
"confidence" => confidence.to_s,
|
|
39
|
+
"depth" => depth
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The answer to one question, in a form both humans and other tools can read.
|
|
45
|
+
#
|
|
46
|
+
# The hash produced by {#to_h} is a public interface: it carries a schema version
|
|
47
|
+
# and its ordering is deterministic, so a snapshot test of it is meaningful.
|
|
48
|
+
class Result
|
|
49
|
+
attr_reader :query_type, :query_value, :changed_nodes, :impacts, :tests,
|
|
50
|
+
:risk, :diagnostics, :stats
|
|
51
|
+
|
|
52
|
+
# @param query_type [Symbol] :symbol or :diff
|
|
53
|
+
# @param query_value [String] what was asked
|
|
54
|
+
# @param changed_nodes [Array<Node>]
|
|
55
|
+
# @param impacts [Array<Traversal::Impact>]
|
|
56
|
+
# @param tests [Array<TestSuggestion>]
|
|
57
|
+
# @param risk [Risk]
|
|
58
|
+
# @param diagnostics [Array<Diagnostic>]
|
|
59
|
+
# @param stats [Hash]
|
|
60
|
+
def initialize(query_type:, query_value:, changed_nodes:, impacts:, tests:, risk:, diagnostics:, stats: {})
|
|
61
|
+
@query_type = query_type
|
|
62
|
+
@query_value = query_value
|
|
63
|
+
@changed_nodes = changed_nodes.sort_by(&:id).freeze
|
|
64
|
+
@impacts = impacts.freeze
|
|
65
|
+
@reportable_impacts = impacts.reject { |impact| impact.node.kind == :file }.freeze
|
|
66
|
+
@tests = tests.freeze
|
|
67
|
+
@risk = risk
|
|
68
|
+
@diagnostics = diagnostics.uniq.sort_by { |d| [d.path.to_s, d.line || 0, d.code] }.freeze
|
|
69
|
+
@stats = stats.freeze
|
|
70
|
+
freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Array<Node>] every impacted node, shortest path first
|
|
74
|
+
def impacted_nodes
|
|
75
|
+
impacts.map(&:node)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Array<String>] ranked test paths, most relevant first
|
|
79
|
+
def test_files
|
|
80
|
+
tests.map(&:path)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Impacts worth showing a human.
|
|
84
|
+
#
|
|
85
|
+
# File nodes exist so that a file-level change can reach the code it declares.
|
|
86
|
+
# They matter for traversal and for mapping a diff onto symbols, but as an
|
|
87
|
+
# answer they are noise, so summaries and risk leave them out.
|
|
88
|
+
#
|
|
89
|
+
# @return [Array<Traversal::Impact>]
|
|
90
|
+
attr_reader :reportable_impacts
|
|
91
|
+
|
|
92
|
+
# @return [Integer] the longest evidence path in the result
|
|
93
|
+
def max_depth
|
|
94
|
+
impacts.map(&:depth).max || 0
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Integer] reportable impacts one hop from a changed node
|
|
98
|
+
def direct_count
|
|
99
|
+
reportable_impacts.count { |impact| impact.depth == 1 }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [Integer] reportable impacts more than one hop away
|
|
103
|
+
def transitive_count
|
|
104
|
+
reportable_impacts.length - direct_count
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [Boolean] true when a global/boot file changed, making any narrow
|
|
108
|
+
# answer unreliable
|
|
109
|
+
def unsafe_focus?
|
|
110
|
+
diagnostics.any? { |diagnostic| diagnostic.code == "global_file_changed" }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @return [Array<String>] the global files that triggered {#unsafe_focus?}
|
|
114
|
+
def global_files
|
|
115
|
+
diagnostics.select { |d| d.code == "global_file_changed" }.map(&:path).compact.uniq.sort
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The machine-readable contract. Key order is fixed and every value is a
|
|
119
|
+
# JSON-native type: no symbols, no absolute paths.
|
|
120
|
+
#
|
|
121
|
+
# @return [Hash]
|
|
122
|
+
def to_h
|
|
123
|
+
{
|
|
124
|
+
"schema_version" => SCHEMA_VERSION,
|
|
125
|
+
"tool" => { "name" => "ripple_effect", "version" => VERSION },
|
|
126
|
+
"query" => { "type" => query_type.to_s, "value" => query_value },
|
|
127
|
+
"changed_nodes" => changed_nodes.map(&:to_h),
|
|
128
|
+
"impacted_nodes" => impacts.map(&:to_h),
|
|
129
|
+
"tests" => tests.map(&:to_h),
|
|
130
|
+
"risk" => risk.to_h,
|
|
131
|
+
"diagnostics" => diagnostics.map(&:to_h),
|
|
132
|
+
"stats" => stringify(stats)
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def stringify(value)
|
|
139
|
+
case value
|
|
140
|
+
when Hash then value.to_h { |k, v| [k.to_s, stringify(v)] }
|
|
141
|
+
when Array then value.map { |v| stringify(v) }
|
|
142
|
+
when Symbol then value.to_s
|
|
143
|
+
else value
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RippleEffect
|
|
4
|
+
# Turns graph facts into a coarse risk band, and says why.
|
|
5
|
+
#
|
|
6
|
+
# A ranking aid for reviewing a change, not a prediction of defects. The formula
|
|
7
|
+
# is simple and lives in one place so it can be reasoned about and tested. No
|
|
8
|
+
# percentages: there is no calibrated model behind them.
|
|
9
|
+
class Risk
|
|
10
|
+
LEVELS = %i[low medium high critical].freeze
|
|
11
|
+
|
|
12
|
+
# Points per fact. Direct dependents count for more than distant ones, and
|
|
13
|
+
# reachability from an entry point (route, job, callback, mailer) counts
|
|
14
|
+
# because a request or a queue can trigger it, not just other code.
|
|
15
|
+
DIRECT_DEPENDENT_POINTS = 1.0
|
|
16
|
+
TRANSITIVE_DEPENDENT_POINTS = 0.5
|
|
17
|
+
DIRECT_DEPENDENT_CAP = 8.0
|
|
18
|
+
TRANSITIVE_DEPENDENT_CAP = 8.0
|
|
19
|
+
|
|
20
|
+
ROUTE_POINTS = 2.0
|
|
21
|
+
JOB_POINTS = 2.0
|
|
22
|
+
CALLBACK_POINTS = 2.0
|
|
23
|
+
MAILER_POINTS = 1.0
|
|
24
|
+
NAMESPACE_CHANGE_POINTS = 3.0
|
|
25
|
+
GLOBAL_FILE_POINTS = 3.0
|
|
26
|
+
LAYER_SPREAD_POINTS = 2.0
|
|
27
|
+
|
|
28
|
+
LAYER_SPREAD_THRESHOLD = 3
|
|
29
|
+
|
|
30
|
+
# Score thresholds, inclusive lower bounds.
|
|
31
|
+
THRESHOLDS = [[18.0, :critical], [10.0, :high], [4.0, :medium], [0.0, :low]].freeze
|
|
32
|
+
|
|
33
|
+
# Path prefixes treated as architectural layers, for the spread heuristic.
|
|
34
|
+
LAYERS = {
|
|
35
|
+
"app/controllers/" => "controllers",
|
|
36
|
+
"app/models/" => "models",
|
|
37
|
+
"app/services/" => "services",
|
|
38
|
+
"app/jobs/" => "jobs",
|
|
39
|
+
"app/mailers/" => "mailers",
|
|
40
|
+
"app/views/" => "views",
|
|
41
|
+
"app/channels/" => "channels",
|
|
42
|
+
"lib/" => "lib"
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
attr_reader :level, :score, :reasons
|
|
46
|
+
|
|
47
|
+
def initialize(level:, score:, reasons:)
|
|
48
|
+
@level = level
|
|
49
|
+
@score = score
|
|
50
|
+
@reasons = reasons.freeze
|
|
51
|
+
freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Scores one change.
|
|
55
|
+
#
|
|
56
|
+
# @param impacts [Array<Traversal::Impact>] everything reachable from the change
|
|
57
|
+
# @param changed_nodes [Array<Node>] what changed
|
|
58
|
+
# @param global_files [Array<String>] changed paths matching a global/boot glob
|
|
59
|
+
# @return [Risk]
|
|
60
|
+
def self.calculate(impacts:, changed_nodes: [], global_files: [])
|
|
61
|
+
score = 0.0
|
|
62
|
+
reasons = []
|
|
63
|
+
|
|
64
|
+
# File nodes exist so a file-level change can reach the code it declares.
|
|
65
|
+
# Counting them as dependents would inflate every score by roughly the
|
|
66
|
+
# number of files touched, which says nothing about actual risk.
|
|
67
|
+
counted = impacts.reject { |impact| impact.node.kind == :file }
|
|
68
|
+
direct = counted.count { |impact| impact.depth == 1 }
|
|
69
|
+
transitive = counted.length - direct
|
|
70
|
+
|
|
71
|
+
if direct.positive?
|
|
72
|
+
score += [direct * DIRECT_DEPENDENT_POINTS, DIRECT_DEPENDENT_CAP].min
|
|
73
|
+
reasons << "#{direct} direct #{pluralize(direct, 'dependent')}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if transitive.positive?
|
|
77
|
+
score += [transitive * TRANSITIVE_DEPENDENT_POINTS, TRANSITIVE_DEPENDENT_CAP].min
|
|
78
|
+
reasons << "#{transitive} transitive #{pluralize(transitive, 'dependent')}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
score += score_entry_points(impacts, reasons)
|
|
82
|
+
score += score_change_shape(changed_nodes, reasons)
|
|
83
|
+
score += score_global_files(global_files, reasons)
|
|
84
|
+
score += score_layer_spread(impacts, reasons)
|
|
85
|
+
|
|
86
|
+
new(level: level_for(score), score: score.round(2), reasons: reasons)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [Symbol] the band for a numeric score
|
|
90
|
+
def self.level_for(score)
|
|
91
|
+
THRESHOLDS.find { |threshold, _| score >= threshold }.last
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @return [Integer] ordering rank, higher is riskier
|
|
95
|
+
def self.rank(level)
|
|
96
|
+
LEVELS.index(level.to_s.to_sym) || 0
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [Boolean] true when +level+ is at least as risky as +threshold+
|
|
100
|
+
def at_least?(threshold)
|
|
101
|
+
self.class.rank(level) >= self.class.rank(threshold)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Hash] JSON-compatible representation
|
|
105
|
+
def to_h
|
|
106
|
+
{ "level" => level.to_s, "score" => score, "reasons" => reasons }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class << self
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def score_entry_points(impacts, reasons)
|
|
113
|
+
score = 0.0
|
|
114
|
+
|
|
115
|
+
{
|
|
116
|
+
route: [ROUTE_POINTS, "route"],
|
|
117
|
+
job: [JOB_POINTS, "background job"],
|
|
118
|
+
callback: [CALLBACK_POINTS, "model callback"],
|
|
119
|
+
mailer_action: [MAILER_POINTS, "mailer"]
|
|
120
|
+
}.each do |kind, (points, label)|
|
|
121
|
+
count = impacts.count { |impact| impact.node.kind == kind }
|
|
122
|
+
next if count.zero?
|
|
123
|
+
|
|
124
|
+
score += points
|
|
125
|
+
reasons << "reachable from #{count} #{pluralize(count, label)}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
score
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Changing a whole class body is broader than changing one method inside it.
|
|
132
|
+
def score_change_shape(changed_nodes, reasons)
|
|
133
|
+
return 0.0 unless changed_nodes.any?(&:namespace?)
|
|
134
|
+
|
|
135
|
+
reasons << "change touches a class or module body, not just method bodies"
|
|
136
|
+
NAMESPACE_CHANGE_POINTS
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def score_global_files(global_files, reasons)
|
|
140
|
+
return 0.0 if global_files.empty?
|
|
141
|
+
|
|
142
|
+
reasons << "changes #{global_files.length} global/boot #{pluralize(global_files.length, 'file')} " \
|
|
143
|
+
"(#{global_files.sort.first(3).join(', ')})"
|
|
144
|
+
GLOBAL_FILE_POINTS
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A change confined to one layer is easier to reason about than one that
|
|
148
|
+
# ripples from models through services into controllers.
|
|
149
|
+
def score_layer_spread(impacts, reasons)
|
|
150
|
+
layers = impacts.filter_map { |impact| layer_for(impact.node.path) }.uniq
|
|
151
|
+
return 0.0 if layers.length < LAYER_SPREAD_THRESHOLD
|
|
152
|
+
|
|
153
|
+
reasons << "impact spans #{layers.length} architectural layers (#{layers.sort.join(', ')})"
|
|
154
|
+
LAYER_SPREAD_POINTS
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def layer_for(path)
|
|
158
|
+
LAYERS.each { |prefix, name| return name if path.to_s.start_with?(prefix) }
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def pluralize(count, word)
|
|
163
|
+
count == 1 ? word : "#{word}s"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RippleEffect
|
|
4
|
+
# The boundary between Ripple Effect and whichever Ruby indexer backs it.
|
|
5
|
+
#
|
|
6
|
+
# Rubydex is young and evolving, so nothing outside this namespace is allowed to
|
|
7
|
+
# see a Rubydex object. Adapters normalise into the plain value objects defined
|
|
8
|
+
# here the moment results come back.
|
|
9
|
+
module StaticIndex
|
|
10
|
+
# A class, module, or method found in the project.
|
|
11
|
+
#
|
|
12
|
+
# @!attribute kind
|
|
13
|
+
# @return [Symbol] :class, :module, :instance_method, or :class_method
|
|
14
|
+
# @!attribute qualified_name
|
|
15
|
+
# @return [String] canonical name, e.g. "Commerce::Order#save" or "User.find"
|
|
16
|
+
Declaration = Struct.new(
|
|
17
|
+
:kind, :qualified_name, :name, :path, :start_line, :end_line,
|
|
18
|
+
:owner_name, :superclass_name, :mixins,
|
|
19
|
+
keyword_init: true
|
|
20
|
+
) do
|
|
21
|
+
# @return [Boolean]
|
|
22
|
+
def method? = %i[instance_method class_method].include?(kind)
|
|
23
|
+
|
|
24
|
+
# @return [Boolean]
|
|
25
|
+
def namespace? = %i[class module].include?(kind)
|
|
26
|
+
|
|
27
|
+
# @return [Array<Hash>] {type: :include|:prepend|:extend, target: String}
|
|
28
|
+
def mixins = self[:mixins] || []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A call site. +receiver_name+ is set only when the indexer resolved the
|
|
32
|
+
# receiver to a concrete constant; a nil receiver means we must either infer
|
|
33
|
+
# the target ourselves at lower confidence, or report it as unresolved.
|
|
34
|
+
MethodReference = Struct.new(
|
|
35
|
+
:name, :receiver_name, :path, :line, :enclosing_name,
|
|
36
|
+
keyword_init: true
|
|
37
|
+
) do
|
|
38
|
+
# @return [Boolean] true when the indexer resolved the receiver
|
|
39
|
+
def resolved_receiver? = !receiver_name.nil?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A constant mention. +target_name+ is nil when the constant could not be
|
|
43
|
+
# resolved to an indexed declaration (a gem constant, or a typo).
|
|
44
|
+
ConstantReference = Struct.new(
|
|
45
|
+
:name, :target_name, :path, :line, :enclosing_name,
|
|
46
|
+
keyword_init: true
|
|
47
|
+
) do
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
def resolved? = !target_name.nil?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Abstract interface every static index backend must satisfy.
|
|
53
|
+
#
|
|
54
|
+
# @abstract
|
|
55
|
+
class Adapter
|
|
56
|
+
# Indexes the given absolute paths.
|
|
57
|
+
# @param paths [Array<String>]
|
|
58
|
+
# @return [self]
|
|
59
|
+
def index(paths:) = raise NotImplementedError
|
|
60
|
+
|
|
61
|
+
# @return [Array<Declaration>]
|
|
62
|
+
def declarations = raise NotImplementedError
|
|
63
|
+
|
|
64
|
+
# @return [Array<MethodReference>]
|
|
65
|
+
def method_references = raise NotImplementedError
|
|
66
|
+
|
|
67
|
+
# @return [Array<ConstantReference>]
|
|
68
|
+
def constant_references = raise NotImplementedError
|
|
69
|
+
|
|
70
|
+
# @return [Array<Diagnostic>] non-fatal problems encountered while indexing
|
|
71
|
+
def diagnostics = raise NotImplementedError
|
|
72
|
+
|
|
73
|
+
# The innermost declaration containing +line+ in +path+.
|
|
74
|
+
# @return [Declaration, nil]
|
|
75
|
+
def declaration_at(path:, line:) = raise NotImplementedError
|
|
76
|
+
|
|
77
|
+
# @return [Array<Declaration>] every declaration in +path+
|
|
78
|
+
def declarations_in(path:) = raise NotImplementedError
|
|
79
|
+
|
|
80
|
+
# @return [String] backend name and version, for `doctor` and cache keys
|
|
81
|
+
def backend_version = raise NotImplementedError
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|