ruby-reforge 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.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rainbow"
4
+
5
+ module Ruby
6
+ module Reforge
7
+ class Reporter
8
+ def report(issues, current_version, target_version)
9
+ say "\n" + "=" * 80, :cyan
10
+ say "📊 Upgrade Report: Ruby #{current_version || 'unknown'} → #{target_version}", :cyan
11
+ say "=" * 80, :cyan
12
+ say ""
13
+
14
+ if issues.empty?
15
+ say "✅ No issues found! Your code is ready for Ruby #{target_version}.", :green
16
+ return
17
+ end
18
+
19
+ # Group issues by type
20
+ by_type = issues.group_by(&:type)
21
+ by_severity = issues.group_by(&:severity)
22
+
23
+ # Summary
24
+ say "Summary:", :yellow
25
+ say " Total issues found: #{issues.size}", :white
26
+ say " Errors: #{by_severity[:error]&.size || 0}", :red
27
+ say " Warnings: #{by_severity[:warning]&.size || 0}", :yellow
28
+ say " Info: #{by_severity[:info]&.size || 0}", :blue
29
+ say ""
30
+
31
+ # Issues by file
32
+ by_file = issues.group_by(&:file)
33
+ say "Issues by file:", :yellow
34
+ say ""
35
+
36
+ by_file.each do |file, file_issues|
37
+ relative_path = file.start_with?("/") ? file : file
38
+ say " #{relative_path} (#{file_issues.size} issue(s))", :white
39
+ file_issues.each do |issue|
40
+ severity_color = case issue.severity
41
+ when :error then :red
42
+ when :warning then :yellow
43
+ else :blue
44
+ end
45
+
46
+ say " Line #{issue.line}: #{issue.message}", severity_color
47
+ if issue.old_code && issue.new_code
48
+ say " Old: #{issue.old_code}", :red
49
+ say " New: #{issue.new_code}", :green
50
+ end
51
+ end
52
+ say ""
53
+ end
54
+
55
+ # Breaking changes
56
+ if by_type[:breaking_change]
57
+ say "⚠️ Breaking Changes:", :red
58
+ by_type[:breaking_change].each do |issue|
59
+ say " - #{issue.message}", :red
60
+ end
61
+ say ""
62
+ end
63
+
64
+ # Recommendations
65
+ say "Recommendations:", :yellow
66
+ say " 1. Review all deprecated method calls", :white
67
+ say " 2. Test thoroughly after applying fixes", :white
68
+ say " 3. Update dependencies: bundle update", :white
69
+ say " 4. Check for gem compatibility with Ruby #{target_version}", :white
70
+ say ""
71
+
72
+ say "Run 'ruby-reforge upgrade #{target_version}' to apply automatic fixes.", :green
73
+ end
74
+
75
+ private
76
+
77
+ def say(message, color = nil)
78
+ puts Rainbow(message).color(color)
79
+ end
80
+ end
81
+ end
82
+ end
83
+
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ruby
6
+ module Reforge
7
+ class Rewriter
8
+ def initialize(root_path)
9
+ @root_path = root_path
10
+ end
11
+
12
+ def rewrite(issues, interactive: false)
13
+ grouped_issues = issues.group_by(&:file)
14
+ fixed_count = 0
15
+
16
+ grouped_issues.each do |file_path, file_issues|
17
+ next unless File.exist?(file_path)
18
+
19
+ content = File.read(file_path)
20
+ lines = content.lines
21
+ modified = false
22
+
23
+ # Sort issues by line number (descending) to avoid offset issues
24
+ file_issues.sort_by { |i| -i.line }.each do |issue|
25
+ case issue.type
26
+ when :deprecated_method, :deprecated_pattern
27
+ if interactive
28
+ next unless confirm_fix?(issue)
29
+ end
30
+
31
+ line_index = issue.line - 1
32
+ if line_index < lines.length
33
+ old_line = lines[line_index]
34
+ new_line = apply_fix(old_line, issue)
35
+ if old_line != new_line
36
+ lines[line_index] = new_line
37
+ modified = true
38
+ fixed_count += 1
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ if modified
45
+ File.write(file_path, lines.join)
46
+ say "✓ Fixed #{file_issues.size} issue(s) in #{File.basename(file_path)}", :green
47
+ end
48
+ end
49
+
50
+ fixed_count
51
+ end
52
+
53
+ private
54
+
55
+ def apply_fix(line, issue)
56
+ case issue.type
57
+ when :deprecated_method
58
+ # Replace deprecated method calls
59
+ if issue.old_code && issue.new_code
60
+ # Try exact match first
61
+ if line.include?(issue.old_code)
62
+ line.gsub(issue.old_code, issue.new_code)
63
+ else
64
+ # Try with method call syntax
65
+ line.gsub(/#{Regexp.escape(issue.old_code)}/, issue.new_code)
66
+ end
67
+ else
68
+ line
69
+ end
70
+ when :deprecated_pattern
71
+ # Apply pattern-based fixes - use the new_code which should be the replacement
72
+ # The issue.new_code contains the full line replacement
73
+ if issue.new_code
74
+ issue.new_code
75
+ else
76
+ line
77
+ end
78
+ else
79
+ line
80
+ end
81
+ end
82
+
83
+ def confirm_fix?(issue)
84
+ require "tty-prompt"
85
+ prompt = TTY::Prompt.new
86
+ prompt.yes?("Fix: #{issue.message} at #{File.basename(issue.file)}:#{issue.line}?")
87
+ end
88
+
89
+ def say(message, color = nil)
90
+ require "rainbow"
91
+ puts Rainbow(message).color(color)
92
+ end
93
+ end
94
+ end
95
+ end
96
+
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "find"
5
+
6
+ module Ruby
7
+ module Reforge
8
+ class Scanner
9
+ def initialize(root_path)
10
+ @root_path = root_path
11
+ @issues = []
12
+ end
13
+
14
+ def scan(target_version)
15
+ @target_version = normalize_version(target_version)
16
+ @issues = []
17
+
18
+ # Get migration rules for target version
19
+ rules = MigrationRules.for_version(@target_version)
20
+
21
+ # Add Rails rules if Rails project detected
22
+ if RailsRules.detect_rails_project?(@root_path)
23
+ rules = merge_rails_rules(rules)
24
+ end
25
+
26
+ # Scan Ruby files
27
+ scan_ruby_files(rules)
28
+
29
+ @issues
30
+ end
31
+
32
+ private
33
+
34
+ def scan_ruby_files(rules)
35
+ ruby_files.each do |file_path|
36
+ begin
37
+ content = File.read(file_path)
38
+ parse_result = Prism.parse(content, file_path)
39
+
40
+ if parse_result.success?
41
+ scan_ast(parse_result.value, file_path, rules)
42
+ else
43
+ # File has syntax errors, but we can still scan for patterns
44
+ scan_file_content(content, file_path, rules)
45
+ end
46
+ rescue => e
47
+ # Skip files that can't be parsed
48
+ next
49
+ end
50
+ end
51
+ end
52
+
53
+ def scan_ast(node, file_path, rules)
54
+ # Scan for keyword argument issues (Ruby 3.0+)
55
+ if @target_version >= "3.0.0"
56
+ scan_keyword_arguments(node, file_path)
57
+ end
58
+
59
+ # Scan for deprecated method calls
60
+ scan_deprecated_methods(node, file_path, rules)
61
+
62
+ # Recursively scan child nodes
63
+ node.child_nodes.each do |child|
64
+ scan_ast(child, file_path, rules) if child.respond_to?(:child_nodes)
65
+ end
66
+ end
67
+
68
+ def scan_keyword_arguments(node, file_path)
69
+ # Detect method definitions that might need **args
70
+ if node.is_a?(Prism::DefNode)
71
+ # Check if method accepts keyword arguments without **
72
+ # This is a simplified check - real implementation would be more sophisticated
73
+ end
74
+ end
75
+
76
+ def scan_deprecated_methods(node, file_path, rules)
77
+ rules.deprecated_methods.each do |deprecated_method, replacement|
78
+ if node.is_a?(Prism::CallNode) && node.name == deprecated_method.to_sym
79
+ @issues << Issue.new(
80
+ type: :deprecated_method,
81
+ file: file_path,
82
+ line: node.location.start_line,
83
+ message: "#{deprecated_method} is deprecated, use #{replacement} instead",
84
+ old_code: deprecated_method.to_s,
85
+ new_code: replacement.to_s
86
+ )
87
+ end
88
+ end
89
+ end
90
+
91
+ def scan_file_content(content, file_path, rules)
92
+ lines = content.lines
93
+
94
+ rules.deprecated_patterns.each do |pattern, replacement|
95
+ lines.each_with_index do |line, index|
96
+ if line.match?(pattern)
97
+ # Preserve indentation when replacing
98
+ indent = line[/\A\s*/]
99
+ new_line = line.gsub(pattern, replacement)
100
+ # Ensure new line ends properly
101
+ new_line = new_line.chomp + "\n" unless new_line.end_with?("\n")
102
+
103
+ @issues << Issue.new(
104
+ type: :deprecated_pattern,
105
+ file: file_path,
106
+ line: index + 1,
107
+ message: "Deprecated pattern found: #{pattern.source}",
108
+ old_code: line,
109
+ new_code: new_line
110
+ )
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+
117
+ def ruby_files
118
+ @ruby_files ||= begin
119
+ files = []
120
+ Find.find(@root_path) do |path|
121
+ next if File.directory?(path)
122
+ next if path.include?("/vendor/")
123
+ next if path.include?("/node_modules/")
124
+ next if path.include?("/.git/")
125
+ next if path.include?("/tmp/")
126
+ next if path.include?("/log/")
127
+
128
+ files << path if path.end_with?(".rb")
129
+ end
130
+ files
131
+ end
132
+ end
133
+
134
+ def normalize_version(version)
135
+ parts = version.to_s.split(".").map(&:to_i)
136
+ parts << 0 while parts.size < 3
137
+ parts[0..2].join(".")
138
+ end
139
+
140
+ def merge_rails_rules(rules)
141
+ # Merge Rails deprecations into existing rules
142
+ merged_methods = rules.deprecated_methods.merge(RailsRules.deprecated_methods)
143
+ merged_patterns = rules.deprecated_patterns.merge(RailsRules.deprecated_patterns)
144
+
145
+ merged_rules = MigrationRules.new
146
+ merged_rules.instance_variable_set(:@deprecated_methods, merged_methods)
147
+ merged_rules.instance_variable_set(:@deprecated_patterns, merged_patterns)
148
+ merged_rules.instance_variable_set(:@breaking_changes, rules.breaking_changes)
149
+ merged_rules
150
+ end
151
+ end
152
+
153
+ class Issue
154
+ attr_reader :type, :file, :line, :message, :old_code, :new_code
155
+
156
+ def initialize(type:, file:, line:, message:, old_code: nil, new_code: nil)
157
+ @type = type
158
+ @file = file
159
+ @line = line
160
+ @message = message
161
+ @old_code = old_code
162
+ @new_code = new_code
163
+ end
164
+
165
+ def severity
166
+ case @type
167
+ when :deprecated_method, :deprecated_pattern
168
+ :warning
169
+ when :breaking_change
170
+ :error
171
+ else
172
+ :info
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
178
+
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruby
4
+ module Reforge
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
8
+
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ruby
6
+ module Reforge
7
+ class VersionUpdater
8
+ def initialize(root_path)
9
+ @root_path = root_path
10
+ end
11
+
12
+ def update(target_version)
13
+ updated_files = []
14
+
15
+ # Update .ruby-version
16
+ ruby_version_path = File.join(@root_path, ".ruby-version")
17
+ if File.exist?(ruby_version_path)
18
+ File.write(ruby_version_path, "#{target_version}\n")
19
+ updated_files << ".ruby-version"
20
+ end
21
+
22
+ # Update Gemfile
23
+ gemfile_path = File.join(@root_path, "Gemfile")
24
+ if File.exist?(gemfile_path)
25
+ update_gemfile(gemfile_path, target_version)
26
+ updated_files << "Gemfile"
27
+ end
28
+
29
+ # Update gemspec files
30
+ Dir.glob(File.join(@root_path, "**/*.gemspec")).each do |gemspec_path|
31
+ update_gemspec(gemspec_path, target_version)
32
+ updated_files << gemspec_path
33
+ end
34
+
35
+ updated_files
36
+ end
37
+
38
+ def detect_current_version
39
+ # Try .ruby-version first
40
+ ruby_version_path = File.join(@root_path, ".ruby-version")
41
+ if File.exist?(ruby_version_path)
42
+ version = File.read(ruby_version_path).strip
43
+ return version if version.match?(/^\d+\.\d+\.\d+/)
44
+ end
45
+
46
+ # Try Gemfile
47
+ gemfile_path = File.join(@root_path, "Gemfile")
48
+ if File.exist?(gemfile_path)
49
+ content = File.read(gemfile_path)
50
+ if match = content.match(/ruby\s+['"]([\d.]+)['"]/)
51
+ return normalize_version(match[1])
52
+ end
53
+ end
54
+
55
+ # Try gemspec
56
+ Dir.glob(File.join(@root_path, "**/*.gemspec")).each do |gemspec_path|
57
+ content = File.read(gemspec_path)
58
+ if match = content.match(/required_ruby_version\s*[>=<]+\s*['"]([\d.]+)['"]/)
59
+ return normalize_version(match[1])
60
+ end
61
+ end
62
+
63
+ nil
64
+ end
65
+
66
+ private
67
+
68
+ def update_gemfile(gemfile_path, target_version)
69
+ content = File.read(gemfile_path)
70
+ updated_content = content.gsub(
71
+ /ruby\s+['"][\d.]+['"]/,
72
+ "ruby '#{target_version}'"
73
+ )
74
+
75
+ # If no ruby version specified, add it after source
76
+ unless content.match(/ruby\s+['"][\d.]+['"]/)
77
+ updated_content = updated_content.sub(
78
+ /(source\s+['"][^'"]+['"])/,
79
+ "\\1\nruby '#{target_version}'"
80
+ )
81
+ end
82
+
83
+ File.write(gemfile_path, updated_content)
84
+ end
85
+
86
+ def update_gemspec(gemspec_path, target_version)
87
+ content = File.read(gemspec_path)
88
+ updated_content = content.gsub(
89
+ /required_ruby_version\s*[>=<]+\s*['"][\d.]+['"]/,
90
+ "required_ruby_version = \">= #{target_version}\""
91
+ )
92
+
93
+ # If no required_ruby_version specified, add it
94
+ unless content.match(/required_ruby_version/)
95
+ updated_content = updated_content.sub(
96
+ /(spec\.version\s*=.*)/,
97
+ "\\1\n spec.required_ruby_version = \">= #{target_version}\""
98
+ )
99
+ end
100
+
101
+ File.write(gemspec_path, updated_content)
102
+ end
103
+
104
+ def normalize_version(version)
105
+ parts = version.split(".").map(&:to_i)
106
+ parts << 0 while parts.size < 3
107
+ parts[0..2].join(".")
108
+ end
109
+ end
110
+ end
111
+ end
112
+
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "reforge/version"
4
+ require_relative "reforge/cli"
5
+ require_relative "reforge/scanner"
6
+ require_relative "reforge/rewriter"
7
+ require_relative "reforge/version_updater"
8
+ require_relative "reforge/migration_rules"
9
+ require_relative "reforge/rails_rules"
10
+ require_relative "reforge/reporter"
11
+ require_relative "reforge/git_integration"
12
+
13
+ module Ruby
14
+ module Reforge
15
+ class Error < StandardError; end
16
+ end
17
+ end
18
+
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/ruby/reforge/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "ruby-reforge"
7
+ spec.version = Ruby::Reforge::VERSION
8
+ spec.authors = ["Afshin Amini"]
9
+ spec.email = ["afshmini@gmail.com"]
10
+
11
+ spec.summary = "Automatically upgrade Ruby projects to newer versions and fix deprecated code"
12
+ spec.description = <<~DESC
13
+ ruby-reforge is a gem that scans a Ruby or Rails project, detects incompatible code
14
+ for a target Ruby version, and automatically rewrites or suggests fixes. It updates
15
+ version files, fixes deprecated syntax, rewrites code for new Ruby syntax, and
16
+ suggests Rails-level incompatibilities.
17
+ DESC
18
+ spec.homepage = "https://github.com/afshmini/ruby-reforge"
19
+ spec.license = "MIT"
20
+ spec.required_ruby_version = ">= 2.7.0"
21
+
22
+ spec.metadata["homepage_uri"] = spec.homepage
23
+ spec.metadata["source_code_uri"] = spec.homepage
24
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
25
+
26
+ spec.files = Dir.chdir(__dir__) do
27
+ `git ls-files -z`.split("\x0").reject do |f|
28
+ (File.expand_path(f) == __FILE__) ||
29
+ f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
30
+ end
31
+ end
32
+ spec.bindir = "exe"
33
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
34
+ spec.require_paths = ["lib"]
35
+
36
+ spec.add_dependency "thor", "~> 1.0"
37
+ spec.add_dependency "prism", "~> 0.24"
38
+ spec.add_dependency "parser", "~> 3.3"
39
+ spec.add_dependency "unparser", "~> 0.6"
40
+ spec.add_dependency "rainbow", "~> 3.1"
41
+ spec.add_dependency "tty-prompt", "~> 0.23"
42
+
43
+ spec.add_development_dependency "bundler", "~> 2.0"
44
+ spec.add_development_dependency "rake", "~> 13.0"
45
+ spec.add_development_dependency "rspec", "~> 3.12"
46
+ spec.add_development_dependency "rubocop", "~> 1.50"
47
+ end
48
+