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,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RippleEffect
6
+ module Extractors
7
+ # Extracts dependencies from ERB templates.
8
+ #
9
+ # In a server-rendered Rails app the views are a large part of the dependency
10
+ # graph: helpers, models and partials are used mostly from templates. Skipping
11
+ # them understates the blast radius of every helper while still looking like a
12
+ # complete answer.
13
+ #
14
+ # The Ruby is lifted out of the ERB tags and parsed with Prism, so the same
15
+ # resolution and confidence rules apply as anywhere else. Templates are never
16
+ # rendered and never executed.
17
+ #
18
+ # Also handles `helper_method`, which is what makes a controller method
19
+ # callable from a template.
20
+ class RailsViews < Base
21
+ # Matches an ERB tag, capturing the Ruby inside it.
22
+ ERB_TAG = /<%(={1,2}|-|\#)?(.*?)(-)?%>/m
23
+
24
+ # `render` forms that name another template.
25
+ RENDER_METHODS = %w[render render_to_string].freeze
26
+
27
+ # Rails looks a template up by name; a partial is the same name with an
28
+ # underscore prefix.
29
+ TEMPLATE_EXTENSIONS = %w[.html.erb .erb .turbo_stream.erb .text.erb .js.erb].freeze
30
+
31
+ def self.feature = :views
32
+
33
+ def extract
34
+ # `helper_method` is a real relationship whether or not templates are
35
+ # indexed, so it is not gated on there being any views to find.
36
+ link_helper_methods
37
+
38
+ return if project.view_paths.empty?
39
+
40
+ build_view_nodes
41
+ link_controller_actions
42
+ project.view_paths.each { |path| extract_from_template(path) }
43
+ end
44
+
45
+ private
46
+
47
+ def build_view_nodes
48
+ project.view_paths.each do |path|
49
+ add_node(
50
+ Node.new(
51
+ kind: :view,
52
+ name: path,
53
+ qualified_name: path,
54
+ path: path,
55
+ metadata: { "partial" => partial?(path) }
56
+ )
57
+ )
58
+ end
59
+ end
60
+
61
+ # @return [Node, nil]
62
+ def view_node(path) = graph.node("view:#{path}:0:#{path}")
63
+
64
+ # A controller action renders its conventional template, so a change to the
65
+ # template is reachable from the action, and so from its route.
66
+ def link_controller_actions
67
+ index.declarations.each do |declaration|
68
+ next unless declaration.kind == :instance_method
69
+
70
+ owner = declaration.owner_name
71
+ next unless owner&.end_with?("Controller")
72
+
73
+ action = declaration.qualified_name.split("#").last
74
+ template = conventional_template(owner, action)
75
+ next unless template
76
+
77
+ add_edge(
78
+ from: node_for(declaration.qualified_name),
79
+ into: view_node(template),
80
+ type: :file_reference,
81
+ evidence: "rails.renders_template",
82
+ confidence: Confidence::HIGH,
83
+ location: template,
84
+ metadata: { "template" => template, "action" => action }
85
+ )
86
+ end
87
+ end
88
+
89
+ # `helper_method :current_company` is what makes a controller method
90
+ # reachable from a template. Without it, a change to that method looks
91
+ # confined to the controller when it is really used across the view layer.
92
+ def link_helper_methods
93
+ project.source_paths.each do |path|
94
+ next unless path.start_with?("app/controllers/")
95
+
96
+ each_call_in_class(sources.ast(path)) do |call, namespace|
97
+ next unless namespace
98
+ next unless call.name.to_s == "helper_method"
99
+ next unless call.receiver.nil?
100
+
101
+ literal_positional_names(call).each do |method_name|
102
+ link_exposed_method(namespace, method_name, path, call.location.start_line)
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ # The method is usually defined on the controller but often comes from an
109
+ # included concern, which is why `helper_method` is worth modelling. A
110
+ # definition on the controller is a fact; a uniquely named one elsewhere is
111
+ # the same inference made for any other call.
112
+ def link_exposed_method(namespace, method_name, path, line)
113
+ own = node_for("#{namespace}##{method_name}")
114
+ candidates = own ? [own] : methods_named(method_name)
115
+ return unless candidates.length == 1
116
+
117
+ add_edge(
118
+ from: node_for(namespace),
119
+ into: candidates.first,
120
+ type: :method_call,
121
+ evidence: "rails.helper_method",
122
+ confidence: own ? Confidence::HIGH : Confidence::MEDIUM,
123
+ location: location_for(path, line),
124
+ metadata: { "method" => method_name, "exposed_to" => "views" }
125
+ )
126
+ end
127
+
128
+ # "Admin::UsersController", "show" -> "app/views/admin/users/show.html.erb"
129
+ def conventional_template(controller, action)
130
+ directory = controller.delete_suffix("Controller")
131
+ .split("::")
132
+ .map { |part| underscore(part) }
133
+ .join("/")
134
+
135
+ find_template("#{directory}/#{action}")
136
+ end
137
+
138
+ # @param stem [String] e.g. "orders/show" or "shared/_menu"
139
+ # @return [String, nil] the matching indexed template path
140
+ def find_template(stem)
141
+ TEMPLATE_EXTENSIONS.each do |extension|
142
+ candidate = "app/views/#{stem}#{extension}"
143
+ return candidate if view_index.include?(candidate)
144
+ end
145
+
146
+ nil
147
+ end
148
+
149
+ def view_index
150
+ @view_index ||= project.view_paths.to_h { |path| [path, true] }
151
+ end
152
+
153
+ def extract_from_template(path)
154
+ source = compile(path)
155
+ return unless source
156
+
157
+ result = Prism.parse(source)
158
+ unless result.success?
159
+ add_diagnostic(
160
+ code: "unparsed_file",
161
+ severity: :info,
162
+ path: path,
163
+ message: "could not parse the Ruby compiled from #{path}"
164
+ )
165
+ return
166
+ end
167
+
168
+ from = view_node(path)
169
+ return unless from
170
+
171
+ each_node(result.value) do |node|
172
+ case node
173
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
174
+ link_constant(from, node, path)
175
+ when Prism::CallNode
176
+ link_call(from, node, path)
177
+ end
178
+ end
179
+ end
180
+
181
+ # Extracts the Ruby from a template as a plain sequence of statements.
182
+ #
183
+ # ERB's own compiler is not usable here. Rails templates rely on
184
+ # ActionView's handler, which understands `<%= form_with do |f| %>`;
185
+ # stdlib ERB compiles that to `_erbout << (form_with do |f|).to_s`, which
186
+ # is a syntax error. Emitting each tag's code as a bare statement keeps
187
+ # block forms and `<% end %>` balanced, and is all we need: we are looking
188
+ # for constants, calls and renders, not rendering anything.
189
+ def compile(path)
190
+ source = project.read(path)
191
+ return nil if source.nil?
192
+
193
+ statements = source.scan(ERB_TAG).filter_map do |marker, code, _trailing|
194
+ next if marker == "#" # `<%# a comment %>`
195
+
196
+ code.to_s.strip.delete_suffix("-")
197
+ end
198
+
199
+ statements.reject(&:empty?).join("\n")
200
+ rescue ProjectError => e
201
+ add_diagnostic(code: "unparsed_file", severity: :info, path: path,
202
+ message: "could not read #{path}: #{e.message}")
203
+ nil
204
+ end
205
+
206
+ def link_constant(from, node, path)
207
+ name = constant_path_name(node)
208
+ return unless name
209
+
210
+ add_edge(
211
+ from: from,
212
+ into: node_for(name),
213
+ type: :constant_reference,
214
+ evidence: "view.constant_reference",
215
+ confidence: Confidence::HIGH,
216
+ location: path,
217
+ metadata: { "constant" => name }
218
+ )
219
+ end
220
+
221
+ def link_call(from, node, path)
222
+ name = node.name.to_s
223
+ return link_render(from, node, path) if RENDER_METHODS.include?(name) && node.receiver.nil?
224
+
225
+ # An explicit receiver in a template is usually a local or instance
226
+ # variable of unknown type. Only bare calls (helpers and model-backed
227
+ # methods) are resolvable here.
228
+ return unless node.receiver.nil?
229
+
230
+ link_helper_call(from, name, path)
231
+ end
232
+
233
+ # Same policy as everywhere else: a unique name is a medium-confidence
234
+ # inference, an ambiguous one is worth nothing.
235
+ def link_helper_call(from, name, path)
236
+ candidates = methods_named(name)
237
+ return unless candidates.length == 1
238
+
239
+ add_edge(
240
+ from: from,
241
+ into: candidates.first,
242
+ type: :method_call,
243
+ evidence: "view.helper_call",
244
+ confidence: Confidence::MEDIUM,
245
+ location: path,
246
+ metadata: { "method" => name }
247
+ )
248
+ end
249
+
250
+ # `render "shared/menu"`, `render partial: "shared/menu"`.
251
+ def link_render(from, call, path)
252
+ target = literal_positional_names(call).first || literal_keywords(call)["partial"]
253
+ return unless target
254
+
255
+ template = resolve_partial(target, path)
256
+ return unless template
257
+
258
+ add_edge(
259
+ from: from,
260
+ into: view_node(template),
261
+ type: :file_reference,
262
+ evidence: "rails.render_partial",
263
+ confidence: Confidence::HIGH,
264
+ location: path,
265
+ metadata: { "partial" => target }
266
+ )
267
+ end
268
+
269
+ # A bare name resolves relative to the rendering template's own directory;
270
+ # a name with a slash is relative to app/views.
271
+ def resolve_partial(target, path)
272
+ # A dynamic partial name cannot be resolved statically.
273
+ return nil if target.include?("\#{")
274
+
275
+ if target.include?("/")
276
+ directory, base = target.split("/").then { |parts| [parts[0..-2].join("/"), parts.last] }
277
+ find_template("#{directory}/_#{base}")
278
+ else
279
+ directory = File.dirname(path).delete_prefix("app/views/")
280
+ find_template("#{directory}/_#{target}")
281
+ end
282
+ end
283
+
284
+ def methods_named(name)
285
+ @methods_by_name ||= graph.nodes.select(&:method?).group_by do |node|
286
+ node.qualified_name.to_s.split(/[#.]/).last
287
+ end
288
+
289
+ @methods_by_name.fetch(name, [])
290
+ end
291
+
292
+ def partial?(path) = File.basename(path).start_with?("_")
293
+
294
+ def underscore(name)
295
+ name.gsub(/([a-z\d])([A-Z])/, '\1_\2').gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').downcase
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RippleEffect
6
+ module Extractors
7
+ # Turns the language-level index into graph nodes and edges: files, classes,
8
+ # modules, methods, inheritance, mixins, constant references and method calls.
9
+ #
10
+ # Every other extractor builds on the nodes this one creates, so it always runs
11
+ # first and is never optional.
12
+ class RubyStructure < Base
13
+ def extract
14
+ build_file_nodes
15
+ build_declaration_nodes
16
+ build_containment_edges
17
+ build_inheritance_and_mixin_edges
18
+ build_constant_reference_edges
19
+ build_method_call_edges
20
+ end
21
+
22
+ private
23
+
24
+ def build_file_nodes
25
+ project.source_paths.each do |path|
26
+ add_node(
27
+ Node.new(
28
+ kind: project.test_path?(path) ? :test_file : :file,
29
+ name: path,
30
+ path: path,
31
+ qualified_name: path
32
+ )
33
+ )
34
+ end
35
+ end
36
+
37
+ def build_declaration_nodes
38
+ index.declarations.each do |declaration|
39
+ add_node(
40
+ Node.new(
41
+ kind: declaration.kind,
42
+ name: declaration.qualified_name,
43
+ qualified_name: declaration.qualified_name,
44
+ path: declaration.path,
45
+ start_line: declaration.start_line,
46
+ end_line: declaration.end_line,
47
+ metadata: { "owner" => declaration.owner_name }.compact
48
+ )
49
+ )
50
+ end
51
+ end
52
+
53
+ # A file depends on what it declares: changing a class should reach the file
54
+ # node, and a test file's convention edges hang off the same structure.
55
+ def build_containment_edges
56
+ index.declarations.each do |declaration|
57
+ node = declaration_node(declaration)
58
+ next unless node
59
+
60
+ add_edge(
61
+ from: file_node_for(declaration.path),
62
+ into: node,
63
+ type: :file_reference,
64
+ evidence: "ruby.file_declares",
65
+ confidence: Confidence::HIGH,
66
+ location: node.location
67
+ )
68
+
69
+ next unless declaration.method? && declaration.owner_name
70
+
71
+ # The owning class depends on its methods: a change to `Order#total`
72
+ # is reachable from anything that reaches `Order`.
73
+ add_edge(
74
+ from: node_for(declaration.owner_name),
75
+ into: node,
76
+ type: :file_reference,
77
+ evidence: "ruby.defines_method",
78
+ confidence: Confidence::HIGH,
79
+ location: node.location
80
+ )
81
+ end
82
+ end
83
+
84
+ def build_inheritance_and_mixin_edges
85
+ index.declarations.select(&:namespace?).each do |declaration|
86
+ node = declaration_node(declaration)
87
+ next unless node
88
+
89
+ if declaration.superclass_name
90
+ add_edge(
91
+ from: node,
92
+ into: node_for(declaration.superclass_name),
93
+ type: :inheritance,
94
+ evidence: "ruby.superclass",
95
+ confidence: Confidence::HIGH,
96
+ location: node.location,
97
+ metadata: { "superclass" => declaration.superclass_name }
98
+ )
99
+ end
100
+
101
+ declaration.mixins.each do |mixin|
102
+ add_edge(
103
+ from: node,
104
+ into: node_for(mixin[:target]),
105
+ type: mixin[:type],
106
+ evidence: "ruby.#{mixin[:type]}",
107
+ confidence: Confidence::HIGH,
108
+ location: node.location,
109
+ metadata: { "module" => mixin[:target] }
110
+ )
111
+ end
112
+ end
113
+ end
114
+
115
+ def build_constant_reference_edges
116
+ index.constant_references.each do |reference|
117
+ next unless reference.resolved?
118
+
119
+ from = reference.enclosing_name ? node_for(reference.enclosing_name) : file_node_for(reference.path)
120
+ next unless from
121
+
122
+ add_edge(
123
+ from: from,
124
+ into: node_for(reference.target_name),
125
+ type: :constant_reference,
126
+ evidence: "rubydex.constant_reference",
127
+ confidence: Confidence::HIGH,
128
+ location: location_for(reference.path, reference.line),
129
+ metadata: { "constant" => reference.name }
130
+ )
131
+ end
132
+ end
133
+
134
+ # Call sites come in two flavours. When the indexer resolved the receiver we
135
+ # know exactly which method is meant. When it did not, we look the bare method
136
+ # name up in the index: a unique match is worth a medium-confidence edge, an
137
+ # ambiguous or unknown one is worth a diagnostic and nothing else.
138
+ def build_method_call_edges
139
+ index.method_references.each do |reference|
140
+ from = caller_node(reference)
141
+ next unless from
142
+
143
+ if reference.resolved_receiver?
144
+ link_resolved_call(reference, from)
145
+ else
146
+ link_inferred_call(reference, from)
147
+ end
148
+ end
149
+ end
150
+
151
+ def caller_node(reference)
152
+ if reference.enclosing_name
153
+ node_for(reference.enclosing_name) || file_node_for(reference.path)
154
+ else
155
+ file_node_for(reference.path)
156
+ end
157
+ end
158
+
159
+ def link_resolved_call(reference, from)
160
+ target = node_for("#{reference.receiver_name}##{reference.name}") ||
161
+ node_for("#{reference.receiver_name}.#{reference.name}")
162
+
163
+ return link_inferred_call(reference, from) if target.nil?
164
+
165
+ add_edge(
166
+ from: from,
167
+ into: target,
168
+ type: :method_call,
169
+ evidence: "rubydex.method_reference",
170
+ confidence: Confidence::HIGH,
171
+ location: location_for(reference.path, reference.line),
172
+ metadata: { "method" => reference.name, "receiver" => reference.receiver_name }
173
+ )
174
+ end
175
+
176
+ def link_inferred_call(reference, from)
177
+ candidates = methods_named(reference.name)
178
+
179
+ case candidates.size
180
+ when 0
181
+ nil
182
+ when 1
183
+ add_edge(
184
+ from: from,
185
+ into: candidates.first,
186
+ type: :method_call,
187
+ evidence: "inference.unique_method_name",
188
+ confidence: Confidence::MEDIUM,
189
+ location: location_for(reference.path, reference.line),
190
+ metadata: { "method" => reference.name }
191
+ )
192
+ else
193
+ # Several methods share this name and we cannot tell which is meant.
194
+ # Guessing here is exactly the kind of confident falsehood that would
195
+ # cost the user their trust, so we record the ambiguity instead.
196
+ add_diagnostic(
197
+ code: "unresolved_method_receiver",
198
+ severity: :info,
199
+ path: reference.path,
200
+ line: reference.line,
201
+ message: "cannot resolve receiver for `#{reference.name}`; " \
202
+ "#{candidates.size} methods share that name"
203
+ )
204
+ end
205
+ end
206
+
207
+ # Index of bare method name -> method nodes, built once.
208
+ def methods_named(name)
209
+ @methods_by_name ||= graph.nodes.select(&:method?).group_by do |node|
210
+ node.qualified_name.to_s.split(/[#.]/).last
211
+ end
212
+
213
+ @methods_by_name.fetch(name, [])
214
+ end
215
+
216
+ def declaration_node(declaration)
217
+ graph.find_symbol(declaration.qualified_name).find { |node| node.path == declaration.path }
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RippleEffect
6
+ module Extractors
7
+ # Links test files to the code they cover.
8
+ #
9
+ # Two kinds of evidence, ranked differently. A test that references
10
+ # `BillingService` is strong evidence; one that merely sits at the
11
+ # conventional path is weaker. The structure extractor usually produces the
12
+ # strong edge already, so the convention edge is a safety net for tests that
13
+ # exercise code indirectly.
14
+ class TestConventions < Base
15
+ # app/services/billing_service.rb -> spec/services/billing_service_spec.rb
16
+ RSPEC_SUFFIX = "_spec.rb"
17
+ MINITEST_SUFFIX = "_test.rb"
18
+
19
+ # Controllers are also commonly covered by request specs named after the
20
+ # resource rather than the class, which is a weaker match.
21
+ REQUEST_SPEC_DIRECTORIES = %w[spec/requests spec/controllers test/controllers test/integration].freeze
22
+
23
+ def extract
24
+ project.test_paths.each do |test_path|
25
+ test_node = file_node_for(test_path)
26
+ next unless test_node
27
+
28
+ link_conventional_target(test_path, test_node)
29
+ end
30
+
31
+ link_request_specs
32
+ end
33
+
34
+ private
35
+
36
+ # spec/models/user_spec.rb -> app/models/user.rb
37
+ def link_conventional_target(test_path, test_node)
38
+ source_path = conventional_source_path(test_path)
39
+ return if source_path.nil?
40
+
41
+ source_node = file_node_for(source_path)
42
+ return if source_node.nil?
43
+
44
+ add_edge(
45
+ from: test_node, into: source_node,
46
+ type: :test_convention,
47
+ evidence: evidence_for(test_path),
48
+ confidence: Confidence::HIGH,
49
+ location: test_path,
50
+ metadata: { "source" => source_path }
51
+ )
52
+
53
+ # Reach the declarations in that file too, so inspecting a single method
54
+ # still surfaces the file's spec.
55
+ graph.nodes_by_path(source_path).each do |node|
56
+ next if node.kind == :file
57
+
58
+ add_edge(
59
+ from: test_node, into: node,
60
+ type: :test_convention,
61
+ evidence: evidence_for(test_path),
62
+ confidence: Confidence::MEDIUM,
63
+ location: test_path,
64
+ metadata: { "source" => source_path }
65
+ )
66
+ end
67
+ end
68
+
69
+ def evidence_for(test_path)
70
+ test_path.end_with?(MINITEST_SUFFIX) ? "convention.minitest_path" : "convention.rspec_path"
71
+ end
72
+
73
+ def conventional_source_path(test_path)
74
+ stem = strip_suffix(test_path)
75
+ return nil if stem.nil?
76
+
77
+ relative = stem.split("/", 2).last
78
+ return nil if relative.nil?
79
+
80
+ candidates(relative).find { |candidate| project.indexed?(candidate) }
81
+ end
82
+
83
+ def strip_suffix(test_path)
84
+ return test_path.delete_suffix(RSPEC_SUFFIX) if test_path.end_with?(RSPEC_SUFFIX)
85
+ return test_path.delete_suffix(MINITEST_SUFFIX) if test_path.end_with?(MINITEST_SUFFIX)
86
+
87
+ nil
88
+ end
89
+
90
+ # `spec/models/user` -> app/models/user.rb, then lib/models/user.rb, then
91
+ # the same name anywhere under app/.
92
+ def candidates(relative)
93
+ ["app/#{relative}.rb", "lib/#{relative}.rb", "#{relative}.rb"]
94
+ end
95
+
96
+ # `spec/requests/orders_spec.rb` conventionally covers `OrdersController`,
97
+ # but the naming is looser, so this stays a low-confidence hint.
98
+ def link_request_specs
99
+ project.test_paths.each do |test_path|
100
+ next unless REQUEST_SPEC_DIRECTORIES.any? { |dir| test_path.start_with?("#{dir}/") }
101
+
102
+ test_node = file_node_for(test_path)
103
+ next unless test_node
104
+
105
+ controller = request_spec_controller(test_path)
106
+ next unless controller
107
+
108
+ add_edge(
109
+ from: test_node, into: node_for(controller),
110
+ type: :test_convention,
111
+ evidence: "convention.request_spec_path",
112
+ confidence: Confidence::LOW,
113
+ location: test_path,
114
+ metadata: { "controller" => controller }
115
+ )
116
+ end
117
+ end
118
+
119
+ def request_spec_controller(test_path)
120
+ stem = strip_suffix(test_path)
121
+ return nil if stem.nil?
122
+
123
+ base = stem.split("/").last
124
+ return nil if base.nil?
125
+
126
+ name = base.end_with?("_controller") ? base.delete_suffix("_controller") : base
127
+ namespace = stem.split("/")[2..-2] || []
128
+ parts = (namespace + ["#{name}_controller"]).map { |part| camelize(part) }
129
+ candidate = parts.join("::")
130
+
131
+ node_for(candidate) ? candidate : nil
132
+ end
133
+ end
134
+ end
135
+ end