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