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,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module UpgradeRadar
6
+ class CLI < Thor
7
+ def self.exit_on_failure?
8
+ true
9
+ end
10
+
11
+ desc "scan", "Scan codebase for deprecations and breaking changes"
12
+ option :from, required: true, type: :string, desc: "Source version (e.g., rails-7.2, ruby-3.3)"
13
+ option :to, required: true, type: :string, desc: "Target version (e.g., rails-8.0, ruby-3.4)"
14
+ option :path, type: :string, default: ".", desc: "Path to scan"
15
+ option :format, type: :string, default: "text", enum: %w[text json html], desc: "Output format"
16
+ option :output, type: :string, desc: "Output file path (default: stdout)"
17
+ option :exclude, type: :array, default: [], desc: "Glob patterns to exclude"
18
+ option :config, type: :string, desc: "Path to config file"
19
+ def scan
20
+ from, to = VersionRange.parse(options[:from], options[:to])
21
+ config = Configuration.new(options)
22
+ scanner = Scanner.new(from: from, to: to, config: config)
23
+ result = scanner.run
24
+
25
+ formatter = Formatters::Base.for(options[:format])
26
+ output = formatter.format(result)
27
+
28
+ if options[:output]
29
+ File.write(options[:output], output)
30
+ $stderr.puts "Report written to #{options[:output]}"
31
+ else
32
+ puts output
33
+ end
34
+
35
+ exit(result.exit_code)
36
+ rescue ArgumentError => e
37
+ $stderr.puts "Error: #{e.message}"
38
+ exit(2)
39
+ end
40
+
41
+ desc "rules", "List available rules for a version range"
42
+ option :from, required: true, type: :string
43
+ option :to, required: true, type: :string
44
+ option :format, type: :string, default: "text", enum: %w[text json]
45
+ def rules
46
+ from, to = VersionRange.parse(options[:from], options[:to])
47
+ registry = RuleRegistry.new.load_builtin_rules
48
+ matching = registry.rules_for(from, to)
49
+
50
+ if options[:format] == "json"
51
+ data = matching.map do |r|
52
+ { id: r.id, severity: r.severity_for(to.version), message: r.message, docs: r.docs }
53
+ end
54
+ puts JSON.pretty_generate(data)
55
+ else
56
+ puts "Rules for #{from} → #{to} (#{matching.size} rules):\n\n"
57
+ matching.each do |r|
58
+ severity = r.severity_for(to.version)
59
+ badge = severity == :breaking ? "[BREAKS]" : "[WARN] "
60
+ puts " #{badge} #{r.id}"
61
+ puts " #{r.message}"
62
+ puts ""
63
+ end
64
+ end
65
+ rescue ArgumentError => e
66
+ $stderr.puts "Error: #{e.message}"
67
+ exit(2)
68
+ end
69
+
70
+ desc "version", "Print version"
71
+ def version
72
+ puts "upgrade_radar #{UpgradeRadar::VERSION}"
73
+ end
74
+
75
+ map %w[-v --version] => :version
76
+ end
77
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UpgradeRadar
4
+ class Configuration
5
+ attr_reader :scan_path, :exclude_patterns, :custom_rules_path, :severity_threshold
6
+
7
+ DEFAULT_CONFIG_FILE = ".upgrade_radar.yml"
8
+
9
+ def initialize(options = {})
10
+ @scan_path = File.expand_path(options[:path] || ".")
11
+ @exclude_patterns = options[:exclude] || []
12
+ @custom_rules_path = nil
13
+ @severity_threshold = :all
14
+
15
+ load_config_file(options[:config])
16
+ end
17
+
18
+ private
19
+
20
+ def load_config_file(explicit_path)
21
+ config_path = explicit_path || File.join(@scan_path, DEFAULT_CONFIG_FILE)
22
+ return unless File.exist?(config_path)
23
+
24
+ config = YAML.safe_load_file(config_path, permitted_classes: []) || {}
25
+
26
+ @exclude_patterns += Array(config["exclude"])
27
+ @custom_rules_path = config["custom_rules_path"]
28
+ @severity_threshold = config["severity_threshold"]&.to_sym || @severity_threshold
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UpgradeRadar
4
+ class EffortEstimator
5
+ EFFORT_LABELS = {
6
+ trivial: 0..30,
7
+ small: 31..120,
8
+ medium: 121..480,
9
+ large: 481..Float::INFINITY
10
+ }.freeze
11
+
12
+ def initialize(result)
13
+ @result = result
14
+ end
15
+
16
+ def summary
17
+ total = @result.total_effort_minutes
18
+ {
19
+ total_findings: @result.findings.size,
20
+ breaking: @result.breaking_count,
21
+ warnings: @result.warning_count,
22
+ files_affected: @result.affected_files_count,
23
+ total_effort_minutes: total,
24
+ total_effort_human: humanize_minutes(total),
25
+ effort_level: effort_level(total),
26
+ by_rule: effort_by_rule
27
+ }
28
+ end
29
+
30
+ private
31
+
32
+ def effort_level(minutes)
33
+ EFFORT_LABELS.each do |label, range|
34
+ return label if range.include?(minutes)
35
+ end
36
+ :large
37
+ end
38
+
39
+ def effort_by_rule
40
+ @result.findings_by_rule.transform_values do |findings|
41
+ {
42
+ count: findings.size,
43
+ total_minutes: findings.sum(&:effort_minutes),
44
+ severity: findings.first.severity
45
+ }
46
+ end
47
+ end
48
+
49
+ def humanize_minutes(minutes)
50
+ return "none" if minutes == 0
51
+
52
+ if minutes < 60
53
+ "#{minutes} minutes"
54
+ elsif minutes < 480
55
+ hours = (minutes / 60.0).round(1)
56
+ "~#{hours} hours"
57
+ else
58
+ days = (minutes / 480.0).round(1)
59
+ "~#{days} days"
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UpgradeRadar
4
+ class FileFinder
5
+ RUBY_EXTENSIONS = %w[.rb .rake .gemspec].freeze
6
+ SPECIAL_FILES = %w[Gemfile Rakefile].freeze
7
+ DEFAULT_EXCLUDES = %w[vendor/** node_modules/** tmp/** log/**].freeze
8
+
9
+ def initialize(base_path, exclude_patterns: [])
10
+ @base_path = File.expand_path(base_path)
11
+ @exclude_patterns = DEFAULT_EXCLUDES + exclude_patterns
12
+ end
13
+
14
+ def ruby_files
15
+ pattern = File.join(@base_path, "**", "*")
16
+ Dir.glob(pattern).select { |f| ruby_file?(f) && !excluded?(f) }.sort
17
+ end
18
+
19
+ def gemfile_path
20
+ path = File.join(@base_path, "Gemfile")
21
+ File.exist?(path) ? path : nil
22
+ end
23
+
24
+ private
25
+
26
+ def ruby_file?(path)
27
+ return false unless File.file?(path)
28
+
29
+ ext = File.extname(path)
30
+ return true if RUBY_EXTENSIONS.include?(ext)
31
+
32
+ SPECIAL_FILES.include?(File.basename(path))
33
+ end
34
+
35
+ def excluded?(path)
36
+ relative = path.sub("#{@base_path}/", "")
37
+ @exclude_patterns.any? { |pattern| File.fnmatch?(pattern, relative, File::FNM_PATHNAME) }
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UpgradeRadar
4
+ class Finding
5
+ attr_reader :rule, :filepath, :line, :column, :source, :severity
6
+
7
+ def initialize(rule:, filepath:, line:, column:, source:, severity:)
8
+ @rule = rule
9
+ @filepath = filepath
10
+ @line = line
11
+ @column = column
12
+ @source = source.to_s.strip
13
+ @severity = severity
14
+ end
15
+
16
+ def id
17
+ rule.id
18
+ end
19
+
20
+ def message
21
+ rule.message
22
+ end
23
+
24
+ def effort_minutes
25
+ rule.effort_minutes
26
+ end
27
+
28
+ def to_h
29
+ {
30
+ id: rule.id,
31
+ severity: severity,
32
+ file: filepath,
33
+ line: line,
34
+ column: column,
35
+ source: source,
36
+ message: rule.message,
37
+ replacement: rule.replacement,
38
+ docs: rule.docs,
39
+ effort_minutes: effort_minutes
40
+ }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UpgradeRadar
4
+ module Formatters
5
+ class Base
6
+ def format(_result)
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def self.for(format_name)
11
+ case format_name.to_s
12
+ when "text" then TextFormatter.new
13
+ when "json" then JsonFormatter.new
14
+ when "html" then HtmlFormatter.new
15
+ else raise ArgumentError, "Unknown format: #{format_name}. Supported: text, json, html"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UpgradeRadar
4
+ module Formatters
5
+ class HtmlFormatter < Base
6
+ include ERB::Util
7
+
8
+ TEMPLATE_PATH = File.expand_path("../templates/report.html.erb", __dir__)
9
+
10
+ def format(result)
11
+ @result = result
12
+ @estimator = EffortEstimator.new(result)
13
+ @summary = @estimator.summary
14
+ template = File.read(TEMPLATE_PATH)
15
+ erb = ERB.new(template, trim_mode: "-")
16
+ erb.result(binding)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UpgradeRadar
4
+ module Formatters
5
+ class JsonFormatter < Base
6
+ def format(result)
7
+ estimator = EffortEstimator.new(result)
8
+
9
+ data = {
10
+ metadata: {
11
+ upgrade_radar_version: UpgradeRadar::VERSION,
12
+ from: result.from_version&.to_s,
13
+ to: result.to_version&.to_s,
14
+ scanned_at: Time.now.strftime("%Y-%m-%dT%H:%M:%S%z"),
15
+ scan_duration_seconds: result.scan_duration.round(3),
16
+ files_scanned: result.files_scanned
17
+ },
18
+ summary: estimator.summary,
19
+ findings: result.findings.map(&:to_h)
20
+ }
21
+
22
+ JSON.pretty_generate(data)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UpgradeRadar
4
+ module Formatters
5
+ class TextFormatter < Base
6
+ COLORS = {
7
+ red: "\e[31m",
8
+ yellow: "\e[33m",
9
+ green: "\e[32m",
10
+ bold: "\e[1m",
11
+ dim: "\e[2m",
12
+ reset: "\e[0m"
13
+ }.freeze
14
+
15
+ def format(result)
16
+ output = []
17
+
18
+ output << header(result)
19
+ output << ""
20
+
21
+ if result.findings.empty?
22
+ output << colorize(" No deprecations or breaking changes found!", :green)
23
+ else
24
+ result.findings_by_file.each do |filepath, findings|
25
+ output << colorize(filepath, :bold)
26
+ findings.sort_by(&:line).each do |f|
27
+ output << format_finding(f)
28
+ end
29
+ output << ""
30
+ end
31
+ end
32
+
33
+ output << effort_summary(result)
34
+ output.join("\n")
35
+ end
36
+
37
+ private
38
+
39
+ def header(result)
40
+ lines = []
41
+ lines << colorize("Upgrade Radar v#{UpgradeRadar::VERSION}", :bold)
42
+ if result.from_version && result.to_version
43
+ lines << "Scanning for: #{result.from_version} → #{result.to_version}"
44
+ end
45
+ lines << "Files scanned: #{result.files_scanned}"
46
+ lines.join("\n")
47
+ end
48
+
49
+ def format_finding(finding)
50
+ badge = if finding.severity == :breaking
51
+ colorize("[BREAKS]", :red)
52
+ else
53
+ colorize("[WARN] ", :yellow)
54
+ end
55
+
56
+ lines = []
57
+ lines << " #{badge} Line #{finding.line}: #{finding.message}"
58
+ lines << " #{colorize('Source:', :dim)} #{truncate(finding.source, 100)}"
59
+ lines << " #{colorize('Fix:', :dim)} #{finding.rule.replacement}" if finding.rule.replacement
60
+ lines << " #{colorize('Docs:', :dim)} #{finding.rule.docs}" if finding.rule.docs
61
+ lines.join("\n")
62
+ end
63
+
64
+ def effort_summary(result)
65
+ estimator = EffortEstimator.new(result)
66
+ summary = estimator.summary
67
+
68
+ lines = []
69
+ lines << colorize("=" * 60, :dim)
70
+ lines << colorize("UPGRADE SUMMARY", :bold)
71
+ lines << colorize("=" * 60, :dim)
72
+ lines << " Total findings: #{summary[:total_findings]}"
73
+ lines << " Breaking: #{colorize(summary[:breaking].to_s, summary[:breaking] > 0 ? :red : :green)}"
74
+ lines << " Warnings: #{colorize(summary[:warnings].to_s, summary[:warnings] > 0 ? :yellow : :green)}"
75
+ lines << " Files affected: #{summary[:files_affected]}"
76
+ lines << " Estimated effort: #{colorize(summary[:total_effort_human], :bold)} (#{summary[:effort_level]})"
77
+ lines << ""
78
+ lines << " Scan completed in #{result.scan_duration.round(2)}s"
79
+ lines.join("\n")
80
+ end
81
+
82
+ def colorize(text, color)
83
+ if $stdout.tty?
84
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
85
+ else
86
+ text
87
+ end
88
+ end
89
+
90
+ def truncate(text, max)
91
+ clean = text.gsub(/\s+/, " ")
92
+ clean.length > max ? "#{clean[0...max]}..." : clean
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UpgradeRadar
4
+ module Matchers
5
+ class Base
6
+ def self.for(pattern)
7
+ case pattern["type"]
8
+ when "method_call"
9
+ MethodCallMatcher.new(pattern)
10
+ when "constant_reference"
11
+ ConstantReferenceMatcher.new(pattern)
12
+ when "block_parameter"
13
+ BlockParameterMatcher.new(pattern)
14
+ when "gemfile_dependency"
15
+ GemfileMatcher.new(pattern)
16
+ when "string_mutation"
17
+ StringMutationMatcher.new(pattern)
18
+ else
19
+ raise ArgumentError, "Unknown pattern type: #{pattern['type']}"
20
+ end
21
+ end
22
+
23
+ def initialize(pattern)
24
+ @pattern = pattern
25
+ end
26
+
27
+ def match?(_node, **_opts)
28
+ raise NotImplementedError
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UpgradeRadar
4
+ module Matchers
5
+ class BlockParameterMatcher < Base
6
+ def match?(node, in_parameterless_block: false, **_opts)
7
+ return false unless in_parameterless_block
8
+ if node.is_a?(Prism::ItLocalVariableReadNode)
9
+ return true
10
+ end
11
+ return false unless node.is_a?(Prism::CallNode)
12
+ return false unless node.name == :it
13
+ return false unless node.receiver.nil?
14
+ return false if node.arguments && !node.arguments.arguments.empty?
15
+ true
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UpgradeRadar
4
+ module Matchers
5
+ class ConstantReferenceMatcher < Base
6
+ def initialize(pattern)
7
+ super
8
+ @expected_name = pattern["name"]
9
+ @context = pattern["context"]
10
+ end
11
+
12
+ def match?(node, context: nil, **_opts)
13
+ return false unless constant_name(node) == @expected_name
14
+ return false if @context == "rescue" && context != :rescue
15
+ true
16
+ end
17
+
18
+ private
19
+
20
+ def constant_name(node)
21
+ case node
22
+ when Prism::ConstantReadNode
23
+ node.name.to_s
24
+ when Prism::ConstantPathNode
25
+ build_constant_path(node)
26
+ else
27
+ nil
28
+ end
29
+ end
30
+
31
+ def build_constant_path(node)
32
+ parts = []
33
+ current = node
34
+ while current.is_a?(Prism::ConstantPathNode)
35
+ parts.unshift(current.name.to_s)
36
+ current = current.parent
37
+ end
38
+ parts.unshift(current.name.to_s) if current.is_a?(Prism::ConstantReadNode)
39
+ parts.join("::")
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UpgradeRadar
4
+ module Matchers
5
+ class GemfileMatcher < Base
6
+ def initialize(pattern)
7
+ super
8
+ @required_gems = pattern["gems"] || []
9
+ end
10
+
11
+ def match?(_node, **_opts)
12
+ false
13
+ end
14
+
15
+ def check_gemfile(gemfile_path, ruby_files)
16
+ return [] unless gemfile_path && File.exist?(gemfile_path)
17
+
18
+ declared = extract_gem_declarations(gemfile_path)
19
+ missing = []
20
+
21
+ @required_gems.each do |gem_name|
22
+ next if declared.include?(gem_name)
23
+ next unless used_in_code?(gem_name, ruby_files)
24
+ missing << gem_name
25
+ end
26
+
27
+ missing
28
+ end
29
+
30
+ private
31
+
32
+ def extract_gem_declarations(gemfile_path)
33
+ content = File.read(gemfile_path)
34
+ result = Prism.parse(content)
35
+ gems = Set.new
36
+
37
+ visitor = GemfileVisitor.new
38
+ visitor.visit(result.value)
39
+ gems.merge(visitor.gem_names)
40
+
41
+ gems
42
+ end
43
+
44
+ def used_in_code?(gem_name, ruby_files)
45
+ ruby_files.any? do |f|
46
+ content = File.read(f)
47
+ content.include?("require \"#{gem_name}\"") ||
48
+ content.include?("require '#{gem_name}'")
49
+ end
50
+ end
51
+
52
+ class GemfileVisitor < Prism::Visitor
53
+ attr_reader :gem_names
54
+
55
+ def initialize
56
+ super
57
+ @gem_names = Set.new
58
+ end
59
+
60
+ def visit_call_node(node)
61
+ if node.name == :gem && node.receiver.nil?
62
+ first_arg = node.arguments&.arguments&.first
63
+ @gem_names << first_arg.unescaped if first_arg.is_a?(Prism::StringNode)
64
+ end
65
+ super
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end