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