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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +537 -0
  5. data/exe/upgrade_radar +5 -0
  6. data/lib/upgrade_radar/audit_visitor.rb +121 -0
  7. data/lib/upgrade_radar/cli.rb +77 -0
  8. data/lib/upgrade_radar/configuration.rb +31 -0
  9. data/lib/upgrade_radar/effort_estimator.rb +63 -0
  10. data/lib/upgrade_radar/file_finder.rb +40 -0
  11. data/lib/upgrade_radar/finding.rb +43 -0
  12. data/lib/upgrade_radar/formatters/base.rb +20 -0
  13. data/lib/upgrade_radar/formatters/html_formatter.rb +20 -0
  14. data/lib/upgrade_radar/formatters/json_formatter.rb +26 -0
  15. data/lib/upgrade_radar/formatters/text_formatter.rb +96 -0
  16. data/lib/upgrade_radar/matchers/base.rb +32 -0
  17. data/lib/upgrade_radar/matchers/block_parameter_matcher.rb +19 -0
  18. data/lib/upgrade_radar/matchers/constant_reference_matcher.rb +43 -0
  19. data/lib/upgrade_radar/matchers/gemfile_matcher.rb +70 -0
  20. data/lib/upgrade_radar/matchers/method_call_matcher.rb +202 -0
  21. data/lib/upgrade_radar/matchers/string_mutation_matcher.rb +32 -0
  22. data/lib/upgrade_radar/result.rb +47 -0
  23. data/lib/upgrade_radar/rule.rb +52 -0
  24. data/lib/upgrade_radar/rule_registry.rb +43 -0
  25. data/lib/upgrade_radar/runtime/collector.rb +74 -0
  26. data/lib/upgrade_radar/runtime/middleware.rb +51 -0
  27. data/lib/upgrade_radar/runtime/rails_hook.rb +24 -0
  28. data/lib/upgrade_radar/runtime/ruby_warning_hook.rb +22 -0
  29. data/lib/upgrade_radar/scanner.rb +71 -0
  30. data/lib/upgrade_radar/templates/report.html.erb +125 -0
  31. data/lib/upgrade_radar/version.rb +5 -0
  32. data/lib/upgrade_radar/version_range.rb +38 -0
  33. data/lib/upgrade_radar.rb +33 -0
  34. data/rules/rails/rails_7_1.yml +17 -0
  35. data/rules/rails/rails_7_2.yml +49 -0
  36. data/rules/rails/rails_8_0.yml +81 -0
  37. data/rules/ruby/ruby_3_3.yml +91 -0
  38. data/rules/ruby/ruby_3_4.yml +84 -0
  39. 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