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,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RippleEffect
6
+ module Extractors
7
+ # Extracts Active Job enqueue sites.
8
+ #
9
+ # `InvoiceJob.perform_later(id)` runs `InvoiceJob#perform` later, in another
10
+ # process. Nothing in the call graph says so, which is exactly why a change to
11
+ # `perform` surprises people. No queue or adapter is ever loaded.
12
+ class RailsJobs < Base
13
+ ENQUEUE_METHODS = %w[perform_later perform_now].freeze
14
+
15
+ # `InvoiceJob.set(wait: 5.minutes).perform_later`: the constant is still
16
+ # visible through the chained call.
17
+ CHAIN_METHODS = %w[set].freeze
18
+
19
+ JOB_SUPERCLASSES = %w[ApplicationJob ActiveJob::Base].freeze
20
+
21
+ def self.feature = :jobs
22
+
23
+ def extract
24
+ mark_job_classes
25
+ link_enqueue_sites
26
+ end
27
+
28
+ private
29
+
30
+ # Job classes get their own node kind so risk scoring can see that a change
31
+ # is reachable from background work.
32
+ def mark_job_classes
33
+ index.declarations.select(&:namespace?).each do |declaration|
34
+ next unless job_class?(declaration)
35
+
36
+ perform = node_for("#{declaration.qualified_name}#perform")
37
+ next unless perform
38
+
39
+ job_node = add_node(
40
+ Node.new(
41
+ kind: :job,
42
+ # Distinguished from the plain class node of the same name, which
43
+ # exists too and means something different.
44
+ name: "#{declaration.qualified_name} (job)",
45
+ qualified_name: "job:#{declaration.qualified_name}",
46
+ path: declaration.path,
47
+ start_line: declaration.start_line,
48
+ end_line: declaration.end_line,
49
+ metadata: { "class" => declaration.qualified_name }
50
+ )
51
+ )
52
+
53
+ add_edge(
54
+ from: job_node, into: perform, type: :job_enqueue,
55
+ evidence: "rails.job_perform", confidence: Confidence::HIGH,
56
+ location: job_node.location
57
+ )
58
+ end
59
+ end
60
+
61
+ def job_class?(declaration)
62
+ return true if JOB_SUPERCLASSES.include?(declaration.superclass_name)
63
+ return false if declaration.superclass_name.nil?
64
+
65
+ # ApplicationJob itself is usually the only direct ActiveJob::Base subclass;
66
+ # everything else inherits from it, possibly through another layer.
67
+ ancestor_is_job?(declaration.superclass_name, depth: 0)
68
+ end
69
+
70
+ def ancestor_is_job?(name, depth:)
71
+ return false if depth > 5 || name.nil?
72
+ return true if JOB_SUPERCLASSES.include?(name)
73
+
74
+ parent = namespaces_by_name[name]
75
+ parent && ancestor_is_job?(parent.superclass_name, depth: depth + 1)
76
+ end
77
+
78
+ def link_enqueue_sites
79
+ project.source_paths.each do |path|
80
+ each_node(sources.ast(path)) do |node|
81
+ next unless node.is_a?(Prism::CallNode)
82
+ next unless ENQUEUE_METHODS.include?(node.name.to_s)
83
+
84
+ constant = receiver_constant(node.receiver)
85
+ next unless constant
86
+
87
+ link_enqueue(constant, node, path)
88
+ end
89
+ end
90
+ end
91
+
92
+ # Unwraps `Job.set(...)` to find the constant underneath.
93
+ def receiver_constant(receiver)
94
+ case receiver
95
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
96
+ constant_path_name(receiver)
97
+ when Prism::CallNode
98
+ CHAIN_METHODS.include?(receiver.name.to_s) ? receiver_constant(receiver.receiver) : nil
99
+ end
100
+ end
101
+
102
+ def link_enqueue(constant, call, path)
103
+ target = node_for("#{constant}#perform")
104
+ line = call.location.start_line
105
+
106
+ if target.nil?
107
+ add_diagnostic(
108
+ code: "unresolved_method_receiver",
109
+ severity: :info,
110
+ path: path,
111
+ line: line,
112
+ message: "#{constant}.#{call.name} enqueues a job whose #perform is not indexed"
113
+ )
114
+ return
115
+ end
116
+
117
+ add_edge(
118
+ from: enclosing_node(path, line), into: target,
119
+ type: :job_enqueue, evidence: "rails.#{call.name}",
120
+ confidence: Confidence::HIGH, location: location_for(path, line),
121
+ metadata: { "job" => constant, "enqueue" => call.name.to_s }
122
+ )
123
+ end
124
+
125
+ def enclosing_node(path, line)
126
+ declaration = index.declaration_at(path: path, line: line)
127
+ (declaration && node_for(declaration.qualified_name)) || file_node_for(path)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RippleEffect
6
+ module Extractors
7
+ # Extracts Action Mailer deliveries.
8
+ #
9
+ # `OrderMailer.receipt(order).deliver_later` reaches `OrderMailer#receipt`
10
+ # through a class-level call that exists nowhere in the source: Action Mailer
11
+ # synthesises it from the instance method.
12
+ class RailsMailers < Base
13
+ DELIVERY_METHODS = %w[deliver_later deliver_now deliver_later! deliver_now!].freeze
14
+ MAILER_SUPERCLASSES = %w[ApplicationMailer ActionMailer::Base].freeze
15
+
16
+ def self.feature = :mailers
17
+
18
+ def extract
19
+ mark_mailer_actions
20
+ link_deliveries
21
+ end
22
+
23
+ private
24
+
25
+ def mark_mailer_actions
26
+ mailer_classes.each do |declaration|
27
+ methods_by_owner.fetch(declaration.qualified_name, []).each do |candidate|
28
+ action = candidate.qualified_name.split("#").last
29
+ action_node = add_node(
30
+ Node.new(
31
+ kind: :mailer_action,
32
+ name: "#{declaration.qualified_name}.#{action}",
33
+ qualified_name: "mailer:#{declaration.qualified_name}.#{action}",
34
+ path: candidate.path,
35
+ start_line: candidate.start_line,
36
+ end_line: candidate.end_line,
37
+ metadata: { "mailer" => declaration.qualified_name, "action" => action }
38
+ )
39
+ )
40
+
41
+ add_edge(
42
+ from: action_node, into: node_for(candidate.qualified_name),
43
+ type: :mailer_delivery, evidence: "rails.mailer_action",
44
+ confidence: Confidence::HIGH, location: action_node.location
45
+ )
46
+ end
47
+ end
48
+ end
49
+
50
+ # Instance methods grouped by the class that owns them, built once.
51
+ def methods_by_owner
52
+ @methods_by_owner ||= index.declarations
53
+ .select { |d| d.kind == :instance_method && d.owner_name }
54
+ .group_by(&:owner_name)
55
+ end
56
+
57
+ def mailer_classes
58
+ @mailer_classes ||= index.declarations.select do |declaration|
59
+ declaration.namespace? && mailer_class?(declaration)
60
+ end
61
+ end
62
+
63
+ def mailer_class?(declaration, depth: 0)
64
+ name = declaration.superclass_name
65
+ return false if name.nil? || depth > 5
66
+ return true if MAILER_SUPERCLASSES.include?(name)
67
+
68
+ parent = namespaces_by_name[name]
69
+ parent ? mailer_class?(parent, depth: depth + 1) : false
70
+ end
71
+
72
+ def link_deliveries
73
+ project.source_paths.each do |path|
74
+ each_node(sources.ast(path)) do |node|
75
+ next unless node.is_a?(Prism::CallNode)
76
+ next unless DELIVERY_METHODS.include?(node.name.to_s)
77
+
78
+ link_delivery(node, path)
79
+ end
80
+ end
81
+ end
82
+
83
+ # The receiver of `.deliver_later` is the `OrderMailer.receipt(order)` call.
84
+ def link_delivery(call, path)
85
+ producer = call.receiver
86
+ return unless producer.is_a?(Prism::CallNode)
87
+
88
+ mailer = constant_path_name(producer.receiver)
89
+ return unless mailer
90
+
91
+ action = producer.name.to_s
92
+ line = call.location.start_line
93
+ target = node_for("#{mailer}##{action}")
94
+
95
+ if target.nil?
96
+ add_diagnostic(
97
+ code: "unresolved_method_receiver",
98
+ severity: :info,
99
+ path: path,
100
+ line: line,
101
+ message: "#{mailer}.#{action} is delivered here but the mailer action is not indexed"
102
+ )
103
+ return
104
+ end
105
+
106
+ add_edge(
107
+ from: enclosing_node(path, line), into: target,
108
+ type: :mailer_delivery, evidence: "rails.#{call.name}",
109
+ confidence: Confidence::HIGH, location: location_for(path, line),
110
+ metadata: { "mailer" => mailer, "action" => action, "delivery" => call.name.to_s }
111
+ )
112
+ end
113
+
114
+ def enclosing_node(path, line)
115
+ declaration = index.declaration_at(path: path, line: line)
116
+ (declaration && node_for(declaration.qualified_name)) || file_node_for(path)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RippleEffect
6
+ module Extractors
7
+ # Extracts the routing table from `config/routes.rb`.
8
+ #
9
+ # Conservative by design: reads the literal forms that make up the bulk of
10
+ # real routing files and stays silent about the rest. An unreadable route is a
11
+ # diagnostic; a misread one would misreport the HTTP surface.
12
+ class RailsRoutes < Base
13
+ VERBS = %w[get post put patch delete options head].freeze
14
+ RESOURCE_MACROS = %w[resources resource].freeze
15
+ SCOPE_MACROS = %w[namespace scope resources resource collection member scope_module].freeze
16
+
17
+ # The seven RESTful actions, in Rails' own order.
18
+ PLURAL_ACTIONS = %w[index create new edit show update destroy].freeze
19
+ SINGULAR_ACTIONS = %w[create new edit show update destroy].freeze
20
+
21
+ # Which verb Rails maps each RESTful action to, for display.
22
+ ACTION_VERBS = {
23
+ "index" => "GET", "create" => "POST", "new" => "GET", "edit" => "GET",
24
+ "show" => "GET", "update" => "PATCH", "destroy" => "DELETE"
25
+ }.freeze
26
+
27
+ # Accumulated routing context as we descend through namespace/scope blocks.
28
+ Scope = Struct.new(:path_prefix, :module_prefix, keyword_init: true) do
29
+ def with(path: nil, module_name: nil)
30
+ Scope.new(
31
+ path_prefix: path ? [path_prefix, path].compact.reject(&:empty?).join("/") : path_prefix,
32
+ module_prefix: module_name ? [module_prefix, module_name].compact.reject(&:empty?).join("/") : module_prefix
33
+ )
34
+ end
35
+
36
+ def full_path(suffix)
37
+ "/#{[path_prefix, suffix].compact.reject(&:empty?).join('/').delete_prefix('/')}"
38
+ end
39
+
40
+ def controller_name(controller)
41
+ [module_prefix, controller].compact.reject(&:empty?).join("/")
42
+ end
43
+ end
44
+
45
+ def self.feature = :routes
46
+
47
+ def extract
48
+ routes_files.each do |path|
49
+ ast = sources.ast(path)
50
+ next unless ast
51
+
52
+ draw_block = find_draw_block(ast)
53
+ walk(draw_block || ast, Scope.new(path_prefix: nil, module_prefix: nil), path)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def routes_files
60
+ project.source_paths.select { |path| path == "config/routes.rb" || path.start_with?("config/routes/") }
61
+ end
62
+
63
+ # Everything interesting is inside `Rails.application.routes.draw`.
64
+ def find_draw_block(root)
65
+ found = nil
66
+
67
+ each_node(root) do |node|
68
+ next if found
69
+ next unless node.is_a?(Prism::CallNode)
70
+ next unless node.name.to_s == "draw" && node.block
71
+
72
+ found = node.block.body
73
+ end
74
+
75
+ found
76
+ end
77
+
78
+ def walk(node, scope, path)
79
+ return unless node
80
+
81
+ node.compact_child_nodes.each do |child|
82
+ if child.is_a?(Prism::CallNode)
83
+ handle_call(child, scope, path)
84
+ else
85
+ walk(child, scope, path)
86
+ end
87
+ end
88
+ end
89
+
90
+ def handle_call(call, scope, path)
91
+ name = call.name.to_s
92
+
93
+ if VERBS.include?(name)
94
+ add_verb_route(call, scope, path)
95
+ elsif RESOURCE_MACROS.include?(name)
96
+ add_resource_routes(call, scope, path)
97
+ elsif name == "namespace"
98
+ descend_namespace(call, scope, path)
99
+ elsif name == "scope"
100
+ descend_scope(call, scope, path)
101
+ elsif name == "root"
102
+ add_root_route(call, scope, path)
103
+ elsif call.block
104
+ walk(call.block.body, scope, path)
105
+ end
106
+ end
107
+
108
+ # `get "/health", to: "health#show"`
109
+ def add_verb_route(call, scope, path)
110
+ keywords = literal_keywords(call)
111
+ positional = literal_positional_names(call)
112
+ url = positional.first
113
+ target = keywords["to"] || implicit_target(positional)
114
+ return if url.nil? || target.nil? || !target.include?("#")
115
+
116
+ controller, action = target.split("#", 2)
117
+ register_route(
118
+ verb: call.name.to_s.upcase,
119
+ url: scope.full_path(url.delete_prefix("/")),
120
+ controller: scope.controller_name(controller),
121
+ action: action,
122
+ path: path,
123
+ line: call.location.start_line
124
+ )
125
+ end
126
+
127
+ # `get "health" => "health#show"` puts the target in the positional hash.
128
+ def implicit_target(positional)
129
+ positional.length >= 2 ? positional.last : nil
130
+ end
131
+
132
+ def add_root_route(call, scope, path)
133
+ target = literal_keywords(call)["to"] || literal_positional_names(call).first
134
+ return if target.nil? || !target.include?("#")
135
+
136
+ controller, action = target.split("#", 2)
137
+ register_route(
138
+ verb: "GET", url: scope.full_path(""),
139
+ controller: scope.controller_name(controller), action: action,
140
+ path: path, line: call.location.start_line
141
+ )
142
+ end
143
+
144
+ # `resources :orders, only: %i[index create]`
145
+ def add_resource_routes(call, scope, path)
146
+ name = literal_positional_names(call).first
147
+ return if name.nil?
148
+
149
+ keywords = literal_keywords(call)
150
+ macro = call.name.to_s
151
+ controller = keywords["controller"] || name
152
+ actions = resource_actions(macro, keywords)
153
+ segment = name
154
+
155
+ actions.each do |action|
156
+ register_route(
157
+ verb: ACTION_VERBS.fetch(action, "GET"),
158
+ url: scope.full_path(rest_path(segment, action, macro)),
159
+ controller: scope.controller_name(controller),
160
+ action: action,
161
+ path: path,
162
+ line: call.location.start_line
163
+ )
164
+ end
165
+
166
+ return unless call.block
167
+
168
+ # Nested resources inherit the parent's path prefix.
169
+ walk(call.block.body, scope.with(path: "#{segment}/:#{singularize(segment)}_id"), path)
170
+ end
171
+
172
+ def resource_actions(macro, keywords)
173
+ base = macro == "resource" ? SINGULAR_ACTIONS : PLURAL_ACTIONS
174
+ only = keywords["only"]
175
+ except = keywords["except"]
176
+
177
+ actions = base
178
+ actions &= Array(only).map(&:to_s) if only
179
+ actions -= Array(except).map(&:to_s) if except
180
+ actions
181
+ end
182
+
183
+ def rest_path(segment, action, macro)
184
+ member = macro == "resource" ? segment : "#{segment}/:id"
185
+
186
+ case action
187
+ when "index", "create" then segment
188
+ when "new" then "#{segment}/new"
189
+ when "edit" then "#{member}/edit"
190
+ else member
191
+ end
192
+ end
193
+
194
+ # `namespace :admin` prefixes both the URL and the controller module.
195
+ def descend_namespace(call, scope, path)
196
+ name = literal_positional_names(call).first
197
+ return unless name && call.block
198
+
199
+ walk(call.block.body, scope.with(path: name, module_name: name), path)
200
+ end
201
+
202
+ # `scope "/api"` prefixes the URL; `scope module: "api"` the controller module.
203
+ def descend_scope(call, scope, path)
204
+ return unless call.block
205
+
206
+ keywords = literal_keywords(call)
207
+ path_prefix = keywords["path"] || literal_positional_names(call).first
208
+
209
+ walk(call.block.body, scope.with(path: path_prefix, module_name: keywords["module"]), path)
210
+ end
211
+
212
+ def register_route(verb:, url:, controller:, action:, path:, line:)
213
+ controller_class = controller_class_name(controller)
214
+ target = node_for("#{controller_class}##{action}")
215
+
216
+ route_node = add_node(
217
+ Node.new(
218
+ kind: :route,
219
+ name: "#{verb} #{url}",
220
+ qualified_name: "route:#{verb}:#{url}",
221
+ path: path,
222
+ start_line: line,
223
+ end_line: line,
224
+ metadata: {
225
+ "verb" => verb, "url" => url, "controller" => controller_class, "action" => action
226
+ }
227
+ )
228
+ )
229
+
230
+ if target.nil?
231
+ add_diagnostic(
232
+ code: "unresolved_route_controller",
233
+ severity: :info,
234
+ path: path,
235
+ line: line,
236
+ message: "#{verb} #{url} routes to #{controller_class}##{action}, which is not indexed"
237
+ )
238
+ return
239
+ end
240
+
241
+ add_edge(
242
+ from: route_node, into: target, type: :route_handler,
243
+ evidence: "rails.route_to_controller", confidence: Confidence::HIGH,
244
+ location: location_for(path, line),
245
+ metadata: { "verb" => verb, "url" => url, "action" => action }
246
+ )
247
+ end
248
+
249
+ # "admin/users" -> "Admin::UsersController"
250
+ def controller_class_name(controller)
251
+ parts = controller.to_s.split("/").map { |part| camelize(part) }
252
+ "#{parts.join('::')}Controller"
253
+ end
254
+ end
255
+ end
256
+ end