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