upgrade_radar 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/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +537 -0
- data/exe/upgrade_radar +5 -0
- data/lib/upgrade_radar/audit_visitor.rb +121 -0
- data/lib/upgrade_radar/cli.rb +77 -0
- data/lib/upgrade_radar/configuration.rb +31 -0
- data/lib/upgrade_radar/effort_estimator.rb +63 -0
- data/lib/upgrade_radar/file_finder.rb +40 -0
- data/lib/upgrade_radar/finding.rb +43 -0
- data/lib/upgrade_radar/formatters/base.rb +20 -0
- data/lib/upgrade_radar/formatters/html_formatter.rb +20 -0
- data/lib/upgrade_radar/formatters/json_formatter.rb +26 -0
- data/lib/upgrade_radar/formatters/text_formatter.rb +96 -0
- data/lib/upgrade_radar/matchers/base.rb +32 -0
- data/lib/upgrade_radar/matchers/block_parameter_matcher.rb +19 -0
- data/lib/upgrade_radar/matchers/constant_reference_matcher.rb +43 -0
- data/lib/upgrade_radar/matchers/gemfile_matcher.rb +70 -0
- data/lib/upgrade_radar/matchers/method_call_matcher.rb +202 -0
- data/lib/upgrade_radar/matchers/string_mutation_matcher.rb +32 -0
- data/lib/upgrade_radar/result.rb +47 -0
- data/lib/upgrade_radar/rule.rb +52 -0
- data/lib/upgrade_radar/rule_registry.rb +43 -0
- data/lib/upgrade_radar/runtime/collector.rb +74 -0
- data/lib/upgrade_radar/runtime/middleware.rb +51 -0
- data/lib/upgrade_radar/runtime/rails_hook.rb +24 -0
- data/lib/upgrade_radar/runtime/ruby_warning_hook.rb +22 -0
- data/lib/upgrade_radar/scanner.rb +71 -0
- data/lib/upgrade_radar/templates/report.html.erb +125 -0
- data/lib/upgrade_radar/version.rb +5 -0
- data/lib/upgrade_radar/version_range.rb +38 -0
- data/lib/upgrade_radar.rb +33 -0
- data/rules/rails/rails_7_1.yml +17 -0
- data/rules/rails/rails_7_2.yml +49 -0
- data/rules/rails/rails_8_0.yml +81 -0
- data/rules/ruby/ruby_3_3.yml +91 -0
- data/rules/ruby/ruby_3_4.yml +84 -0
- metadata +113 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UpgradeRadar
|
|
4
|
+
module Matchers
|
|
5
|
+
class MethodCallMatcher < Base
|
|
6
|
+
def initialize(pattern)
|
|
7
|
+
super
|
|
8
|
+
@expected_names = Array(pattern["name"]).map(&:to_sym)
|
|
9
|
+
@receiver_spec = pattern["receiver"]
|
|
10
|
+
@receiver_chain = pattern["receiver_chain"]
|
|
11
|
+
@receiver_method = pattern["receiver_method"]
|
|
12
|
+
@conditions = pattern.fetch("conditions", []) || []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def match?(node, **_opts)
|
|
16
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
17
|
+
return false unless name_matches?(node)
|
|
18
|
+
return false unless receiver_matches?(node)
|
|
19
|
+
return false unless conditions_match?(node)
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def name_matches?(node)
|
|
26
|
+
@expected_names.include?(node.name)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def receiver_matches?(node)
|
|
30
|
+
if @receiver_chain
|
|
31
|
+
match_receiver_chain?(node)
|
|
32
|
+
elsif @receiver_method
|
|
33
|
+
match_receiver_method?(node)
|
|
34
|
+
elsif @receiver_spec.nil? || @receiver_spec == "null"
|
|
35
|
+
node.receiver.nil?
|
|
36
|
+
elsif @receiver_spec == "any"
|
|
37
|
+
true
|
|
38
|
+
else
|
|
39
|
+
match_receiver_constant?(node, @receiver_spec)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def match_receiver_chain?(node)
|
|
44
|
+
chain = @receiver_chain
|
|
45
|
+
if chain.start_with?("**.")
|
|
46
|
+
method_name = chain.sub("**.", "").to_sym
|
|
47
|
+
node.receiver.is_a?(Prism::CallNode) && node.receiver.name == method_name
|
|
48
|
+
else
|
|
49
|
+
resolve_chain(node.receiver) == chain
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def match_receiver_method?(node)
|
|
54
|
+
return false unless node.receiver.is_a?(Prism::CallNode)
|
|
55
|
+
node.receiver.name == @receiver_method.to_sym
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def match_receiver_constant?(node, name)
|
|
59
|
+
return false unless node.receiver
|
|
60
|
+
resolve_constant_name(node.receiver) == name
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resolve_chain(node)
|
|
64
|
+
return nil unless node
|
|
65
|
+
|
|
66
|
+
parts = []
|
|
67
|
+
current = node
|
|
68
|
+
while current.is_a?(Prism::CallNode)
|
|
69
|
+
parts.unshift(current.name.to_s)
|
|
70
|
+
current = current.receiver
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if current.is_a?(Prism::ConstantReadNode)
|
|
74
|
+
parts.unshift(current.name.to_s)
|
|
75
|
+
elsif current.is_a?(Prism::ConstantPathNode)
|
|
76
|
+
parts.unshift(resolve_constant_name(current))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
parts.join(".")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def resolve_constant_name(node)
|
|
83
|
+
case node
|
|
84
|
+
when Prism::ConstantReadNode
|
|
85
|
+
node.name.to_s
|
|
86
|
+
when Prism::ConstantPathNode
|
|
87
|
+
parts = []
|
|
88
|
+
current = node
|
|
89
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
90
|
+
parts.unshift(current.name.to_s)
|
|
91
|
+
current = current.parent
|
|
92
|
+
end
|
|
93
|
+
parts.unshift(current.name.to_s) if current.is_a?(Prism::ConstantReadNode)
|
|
94
|
+
parts.join("::")
|
|
95
|
+
else
|
|
96
|
+
node.to_s
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def conditions_match?(node)
|
|
101
|
+
@conditions.all? { |cond| evaluate_condition(cond, node) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def evaluate_condition(condition, node)
|
|
105
|
+
key, value = condition.is_a?(Hash) ? condition.first : [condition, true]
|
|
106
|
+
case key
|
|
107
|
+
when "no_keyword"
|
|
108
|
+
!has_keyword_arg?(node, value)
|
|
109
|
+
when "has_block_pass"
|
|
110
|
+
has_block_pass?(node)
|
|
111
|
+
when "first_arg_starts_with"
|
|
112
|
+
first_string_arg_starts_with?(node, value)
|
|
113
|
+
when "first_arg_type"
|
|
114
|
+
first_arg_is_type?(node, value)
|
|
115
|
+
when "keyword_value"
|
|
116
|
+
has_keyword_with_value?(node, value["key"], value["value"])
|
|
117
|
+
when "has_keywords"
|
|
118
|
+
has_any_keywords?(node)
|
|
119
|
+
when "min_positional_args"
|
|
120
|
+
positional_arg_count(node) >= value
|
|
121
|
+
else
|
|
122
|
+
true
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def args_list(node)
|
|
127
|
+
node.arguments&.arguments || []
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def has_keyword_arg?(node, key_name)
|
|
131
|
+
args_list(node).any? do |arg|
|
|
132
|
+
next false unless arg.is_a?(Prism::KeywordHashNode)
|
|
133
|
+
arg.elements.any? do |elem|
|
|
134
|
+
elem.is_a?(Prism::AssocNode) &&
|
|
135
|
+
elem.key.is_a?(Prism::SymbolNode) &&
|
|
136
|
+
elem.key.value == key_name
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def has_block_pass?(node)
|
|
142
|
+
node.block.is_a?(Prism::BlockArgumentNode) ||
|
|
143
|
+
args_list(node).any? { |a| a.is_a?(Prism::BlockArgumentNode) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def first_string_arg_starts_with?(node, prefix)
|
|
147
|
+
first = positional_args(node).first
|
|
148
|
+
first.is_a?(Prism::StringNode) && first.unescaped.start_with?(prefix)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def first_arg_is_type?(node, type_name)
|
|
152
|
+
first = args_list(node).first
|
|
153
|
+
return false unless first
|
|
154
|
+
|
|
155
|
+
case type_name
|
|
156
|
+
when "string"
|
|
157
|
+
first.is_a?(Prism::StringNode)
|
|
158
|
+
when "hash"
|
|
159
|
+
first.is_a?(Prism::KeywordHashNode)
|
|
160
|
+
when "symbol"
|
|
161
|
+
first.is_a?(Prism::SymbolNode)
|
|
162
|
+
when "constant"
|
|
163
|
+
first.is_a?(Prism::ConstantReadNode) || first.is_a?(Prism::ConstantPathNode)
|
|
164
|
+
else
|
|
165
|
+
false
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def has_keyword_with_value?(node, key, expected_value)
|
|
170
|
+
args_list(node).any? do |arg|
|
|
171
|
+
next false unless arg.is_a?(Prism::KeywordHashNode)
|
|
172
|
+
arg.elements.any? do |elem|
|
|
173
|
+
next false unless elem.is_a?(Prism::AssocNode)
|
|
174
|
+
next false unless elem.key.is_a?(Prism::SymbolNode) && elem.key.value == key
|
|
175
|
+
case expected_value
|
|
176
|
+
when "nil"
|
|
177
|
+
elem.value.is_a?(Prism::NilNode)
|
|
178
|
+
when "true"
|
|
179
|
+
elem.value.is_a?(Prism::TrueNode)
|
|
180
|
+
when "false"
|
|
181
|
+
elem.value.is_a?(Prism::FalseNode)
|
|
182
|
+
else
|
|
183
|
+
elem.value.is_a?(Prism::StringNode) && elem.value.unescaped == expected_value
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def has_any_keywords?(node)
|
|
190
|
+
args_list(node).any? { |a| a.is_a?(Prism::KeywordHashNode) }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def positional_args(node)
|
|
194
|
+
args_list(node).reject { |a| a.is_a?(Prism::KeywordHashNode) || a.is_a?(Prism::BlockArgumentNode) }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def positional_arg_count(node)
|
|
198
|
+
positional_args(node).size
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UpgradeRadar
|
|
4
|
+
module Matchers
|
|
5
|
+
class StringMutationMatcher < Base
|
|
6
|
+
MUTATING_METHODS = %i[<< replace gsub! sub! clear []= capitalize! chomp!
|
|
7
|
+
chop! delete! downcase! encode! lstrip! rstrip!
|
|
8
|
+
strip! squeeze! swapcase! tr! tr_s! upcase!
|
|
9
|
+
prepend setbyte unicode_normalize!].freeze
|
|
10
|
+
|
|
11
|
+
def match?(node, **_opts)
|
|
12
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
13
|
+
return false unless MUTATING_METHODS.include?(node.name)
|
|
14
|
+
return false unless string_literal_receiver?(node.receiver)
|
|
15
|
+
true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def string_literal_receiver?(receiver)
|
|
21
|
+
case receiver
|
|
22
|
+
when Prism::StringNode, Prism::InterpolatedStringNode
|
|
23
|
+
true
|
|
24
|
+
when Prism::ParenthesesNode
|
|
25
|
+
receiver.body&.body&.first.is_a?(Prism::StringNode)
|
|
26
|
+
else
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UpgradeRadar
|
|
4
|
+
class Result
|
|
5
|
+
attr_reader :findings, :files_scanned, :scan_duration, :from_version, :to_version
|
|
6
|
+
|
|
7
|
+
def initialize(findings:, files_scanned:, scan_duration:, from_version: nil, to_version: nil)
|
|
8
|
+
@findings = findings
|
|
9
|
+
@files_scanned = files_scanned
|
|
10
|
+
@scan_duration = scan_duration
|
|
11
|
+
@from_version = from_version
|
|
12
|
+
@to_version = to_version
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def breaking_count
|
|
16
|
+
findings.count { |f| f.severity == :breaking }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def warning_count
|
|
20
|
+
findings.count { |f| f.severity == :warning }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def findings_by_file
|
|
24
|
+
findings.group_by(&:filepath)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def findings_by_rule
|
|
28
|
+
findings.group_by(&:id)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def findings_by_severity
|
|
32
|
+
findings.group_by(&:severity)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def total_effort_minutes
|
|
36
|
+
findings.sum(&:effort_minutes)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def affected_files_count
|
|
40
|
+
findings.map(&:filepath).uniq.size
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def exit_code
|
|
44
|
+
breaking_count > 0 ? 1 : 0
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UpgradeRadar
|
|
4
|
+
class Rule
|
|
5
|
+
attr_reader :id, :framework, :introduced_in, :breaking_in,
|
|
6
|
+
:pattern, :message, :replacement, :docs, :effort_minutes, :confidence
|
|
7
|
+
|
|
8
|
+
def initialize(attrs)
|
|
9
|
+
@id = attrs.fetch("id")
|
|
10
|
+
@framework = attrs.fetch("framework")
|
|
11
|
+
@introduced_in = Gem::Version.new(attrs.fetch("introduced_in").to_s)
|
|
12
|
+
@breaking_in = Gem::Version.new(attrs.fetch("breaking_in").to_s)
|
|
13
|
+
@pattern = attrs.fetch("pattern")
|
|
14
|
+
@message = attrs.fetch("message").strip
|
|
15
|
+
@replacement = attrs["replacement"]&.strip
|
|
16
|
+
@docs = attrs["docs"]
|
|
17
|
+
@effort_minutes = attrs.fetch("effort_minutes", 10)
|
|
18
|
+
@confidence = attrs.fetch("confidence", "high")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def applies?(from_version, to_version)
|
|
22
|
+
breaking_in >= from_version && introduced_in <= to_version
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def low_confidence?
|
|
26
|
+
confidence == "low"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Low-confidence rules can't tell statically whether the receiver is
|
|
30
|
+
# affected, so they never fail a scan on their own.
|
|
31
|
+
def severity_for(to_version)
|
|
32
|
+
return :warning if low_confidence?
|
|
33
|
+
|
|
34
|
+
to_version >= breaking_in ? :breaking : :warning
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def node_types
|
|
38
|
+
case pattern["type"]
|
|
39
|
+
when "method_call", "string_mutation"
|
|
40
|
+
[:call_node]
|
|
41
|
+
when "constant_reference"
|
|
42
|
+
[:constant_read_node, :constant_path_node]
|
|
43
|
+
when "block_parameter"
|
|
44
|
+
[:call_node]
|
|
45
|
+
when "gemfile_dependency"
|
|
46
|
+
[:special]
|
|
47
|
+
else
|
|
48
|
+
[]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UpgradeRadar
|
|
4
|
+
class RuleRegistry
|
|
5
|
+
attr_reader :rules
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@rules = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def load_builtin_rules
|
|
12
|
+
Dir[File.join(UpgradeRadar::RULES_PATH, "**", "*.yml")].sort.each do |path|
|
|
13
|
+
load_file(path)
|
|
14
|
+
end
|
|
15
|
+
self
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def load_custom_rules(path)
|
|
19
|
+
Dir[File.join(path, "**", "*.yml")].sort.each do |file_path|
|
|
20
|
+
load_file(file_path)
|
|
21
|
+
end
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def rules_for(from_range, to_range)
|
|
26
|
+
@rules.select do |rule|
|
|
27
|
+
rule.framework == from_range.framework &&
|
|
28
|
+
rule.applies?(from_range.version, to_range.version)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def load_file(path)
|
|
35
|
+
entries = YAML.safe_load_file(path, permitted_classes: []) || []
|
|
36
|
+
entries.each do |attrs|
|
|
37
|
+
rule = Rule.new(attrs)
|
|
38
|
+
raise "Duplicate rule id: #{rule.id}" if @rules.any? { |r| r.id == rule.id }
|
|
39
|
+
@rules << rule
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UpgradeRadar
|
|
4
|
+
module Runtime
|
|
5
|
+
class Collector
|
|
6
|
+
attr_reader :warnings
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@warnings = []
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
@seen = Set.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def add(message, source: nil, backtrace: nil)
|
|
15
|
+
key = message.strip
|
|
16
|
+
@mutex.synchronize do
|
|
17
|
+
return if @seen.include?(key)
|
|
18
|
+
@seen << key
|
|
19
|
+
@warnings << {
|
|
20
|
+
message: key,
|
|
21
|
+
source: source,
|
|
22
|
+
backtrace: backtrace&.first(5)&.map(&:to_s),
|
|
23
|
+
timestamp: Time.now
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_findings
|
|
29
|
+
@warnings.map do |w|
|
|
30
|
+
location = parse_location(w[:backtrace]&.first)
|
|
31
|
+
RuntimeFinding.new(
|
|
32
|
+
message: w[:message],
|
|
33
|
+
source: w[:source],
|
|
34
|
+
filepath: location[:file],
|
|
35
|
+
line: location[:line]
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def size
|
|
41
|
+
@warnings.size
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def clear
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
@warnings.clear
|
|
47
|
+
@seen.clear
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def parse_location(backtrace_line)
|
|
54
|
+
return { file: nil, line: nil } unless backtrace_line
|
|
55
|
+
if backtrace_line =~ /\A(.+):(\d+)/
|
|
56
|
+
{ file: $1, line: $2.to_i }
|
|
57
|
+
else
|
|
58
|
+
{ file: nil, line: nil }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class RuntimeFinding
|
|
64
|
+
attr_reader :message, :source, :filepath, :line
|
|
65
|
+
|
|
66
|
+
def initialize(message:, source:, filepath:, line:)
|
|
67
|
+
@message = message
|
|
68
|
+
@source = source
|
|
69
|
+
@filepath = filepath
|
|
70
|
+
@line = line
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "collector"
|
|
4
|
+
require_relative "rails_hook"
|
|
5
|
+
require_relative "ruby_warning_hook"
|
|
6
|
+
|
|
7
|
+
module UpgradeRadar
|
|
8
|
+
module Runtime
|
|
9
|
+
class Middleware
|
|
10
|
+
attr_reader :collector
|
|
11
|
+
|
|
12
|
+
def initialize(app, collector: nil)
|
|
13
|
+
@app = app
|
|
14
|
+
@collector = collector || Collector.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(env)
|
|
18
|
+
@app.call(env)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.setup_for_rspec(collector = nil)
|
|
22
|
+
collector ||= Collector.new
|
|
23
|
+
|
|
24
|
+
RSpec.configure do |config|
|
|
25
|
+
config.before(:suite) do
|
|
26
|
+
RailsHook.install(collector)
|
|
27
|
+
RubyWarningHook.install(collector)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
config.after(:suite) do
|
|
31
|
+
if collector.size > 0
|
|
32
|
+
$stderr.puts "\n\nUpgrade Radar: #{collector.size} runtime deprecation(s) captured"
|
|
33
|
+
collector.to_findings.each do |f|
|
|
34
|
+
$stderr.puts " #{f.filepath}:#{f.line} #{f.message}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
collector
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.setup_for_minitest(collector = nil)
|
|
44
|
+
collector ||= Collector.new
|
|
45
|
+
RailsHook.install(collector)
|
|
46
|
+
RubyWarningHook.install(collector)
|
|
47
|
+
collector
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UpgradeRadar
|
|
4
|
+
module Runtime
|
|
5
|
+
class RailsHook
|
|
6
|
+
def self.install(collector)
|
|
7
|
+
return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
8
|
+
|
|
9
|
+
if Rails.application.respond_to?(:deprecators)
|
|
10
|
+
Rails.application.deprecators.each do |deprecator|
|
|
11
|
+
original = Array(deprecator.behavior)
|
|
12
|
+
deprecator.behavior = original + [
|
|
13
|
+
->(message, callstack, *) { collector.add(message, source: :rails, backtrace: callstack) }
|
|
14
|
+
]
|
|
15
|
+
end
|
|
16
|
+
elsif defined?(ActiveSupport::Deprecation)
|
|
17
|
+
ActiveSupport::Deprecation.behavior = ActiveSupport::Deprecation.behavior + [
|
|
18
|
+
->(message, callstack, *) { collector.add(message, source: :rails, backtrace: callstack) }
|
|
19
|
+
]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UpgradeRadar
|
|
4
|
+
module Runtime
|
|
5
|
+
class RubyWarningHook
|
|
6
|
+
def self.install(collector)
|
|
7
|
+
Warning[:deprecated] = true if Warning.respond_to?(:[]=)
|
|
8
|
+
|
|
9
|
+
hook = Module.new do
|
|
10
|
+
define_method(:warn) do |message, category: nil, **|
|
|
11
|
+
if category == :deprecated
|
|
12
|
+
collector.add(message, source: :ruby, backtrace: caller)
|
|
13
|
+
end
|
|
14
|
+
super(message, category: category)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Warning.extend(hook)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UpgradeRadar
|
|
4
|
+
class Scanner
|
|
5
|
+
def initialize(from:, to:, config:)
|
|
6
|
+
@from = from
|
|
7
|
+
@to = to
|
|
8
|
+
@config = config
|
|
9
|
+
@registry = RuleRegistry.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def run
|
|
13
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
14
|
+
|
|
15
|
+
@registry.load_builtin_rules
|
|
16
|
+
@registry.load_custom_rules(@config.custom_rules_path) if @config.custom_rules_path
|
|
17
|
+
|
|
18
|
+
active_rules = @registry.rules_for(@from, @to)
|
|
19
|
+
ast_rules = active_rules.reject { |r| r.pattern["type"] == "gemfile_dependency" }
|
|
20
|
+
gemfile_rules = active_rules.select { |r| r.pattern["type"] == "gemfile_dependency" }
|
|
21
|
+
|
|
22
|
+
finder = FileFinder.new(@config.scan_path, exclude_patterns: @config.exclude_patterns)
|
|
23
|
+
ruby_files = finder.ruby_files
|
|
24
|
+
findings = []
|
|
25
|
+
|
|
26
|
+
ruby_files.each do |filepath|
|
|
27
|
+
scan_file(filepath, ast_rules, findings)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
check_gemfile(gemfile_rules, finder, ruby_files, findings)
|
|
31
|
+
|
|
32
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
33
|
+
|
|
34
|
+
Result.new(
|
|
35
|
+
findings: findings,
|
|
36
|
+
files_scanned: ruby_files.size,
|
|
37
|
+
scan_duration: elapsed,
|
|
38
|
+
from_version: @from,
|
|
39
|
+
to_version: @to
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def scan_file(filepath, rules, findings)
|
|
46
|
+
parse_result = Prism.parse_file(filepath)
|
|
47
|
+
visitor = AuditVisitor.new(filepath, rules, @to.version)
|
|
48
|
+
visitor.visit(parse_result.value)
|
|
49
|
+
findings.concat(visitor.findings)
|
|
50
|
+
rescue => e
|
|
51
|
+
$stderr.puts "upgrade_radar: error scanning #{filepath}: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def check_gemfile(gemfile_rules, finder, ruby_files, findings)
|
|
55
|
+
gemfile_rules.each do |rule|
|
|
56
|
+
matcher = Matchers::GemfileMatcher.new(rule.pattern)
|
|
57
|
+
missing = matcher.check_gemfile(finder.gemfile_path, ruby_files)
|
|
58
|
+
missing.each do |gem_name|
|
|
59
|
+
findings << Finding.new(
|
|
60
|
+
rule: rule,
|
|
61
|
+
filepath: finder.gemfile_path || "Gemfile",
|
|
62
|
+
line: 1,
|
|
63
|
+
column: 0,
|
|
64
|
+
source: "missing: gem '#{gem_name}'",
|
|
65
|
+
severity: rule.severity_for(@to.version)
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|