rails-architect 0.1.0 → 0.2.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,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsArchitect
4
+ class KissAnalyzer
5
+ attr_reader :issues
6
+
7
+ def initialize
8
+ @issues = []
9
+ end
10
+
11
+ def analyze
12
+ check_method_complexity
13
+ check_class_size
14
+ check_nesting_depth
15
+ end
16
+
17
+ private
18
+
19
+ def add_issue(file, type, message)
20
+ @issues << { file: file, type: type, message: message }
21
+ end
22
+
23
+ private
24
+
25
+ def check_method_complexity
26
+ Dir.glob("app/**/*.rb").each do |file|
27
+ content = File.read(file)
28
+ methods = extract_methods(content)
29
+
30
+ methods.each do |method_name, method_body|
31
+ complexity = calculate_cyclomatic_complexity(method_body)
32
+ lines = method_body.count("\n") + 1
33
+
34
+ if complexity > 10
35
+ class_name = extract_class_name(file)
36
+ add_issue(file, "kiss_complexity", "#{class_name}##{method_name} has cyclomatic complexity #{complexity}, keep it simple")
37
+ end
38
+
39
+ if lines > 25
40
+ class_name = extract_class_name(file)
41
+ add_issue(file, "kiss_length", "#{class_name}##{method_name} is #{lines} lines long, keep it simple")
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ def check_class_size
48
+ Dir.glob("app/**/*.rb").each do |file|
49
+ content = File.read(file)
50
+ lines = content.count("\n") + 1
51
+
52
+ if lines > 200
53
+ class_name = extract_class_name(file)
54
+ add_issue(file, "kiss_class_size", "#{class_name} is #{lines} lines long, consider splitting into smaller classes")
55
+ end
56
+ end
57
+ end
58
+
59
+ def check_nesting_depth
60
+ Dir.glob("app/**/*.rb").each do |file|
61
+ content = File.read(file)
62
+ max_depth = calculate_max_nesting_depth(content)
63
+
64
+ if max_depth > 4
65
+ class_name = extract_class_name(file)
66
+ add_issue(file, "kiss_nesting", "#{class_name} has nesting depth of #{max_depth}, keep it simple")
67
+ end
68
+ end
69
+ end
70
+
71
+ def extract_methods(content)
72
+ methods = {}
73
+ current_method = nil
74
+ indent_level = 0
75
+ method_start = false
76
+
77
+ content.each_line do |line|
78
+ if line.match?(/^\s*def \w+/)
79
+ current_method = line.strip.match(/def (\w+)/)[1]
80
+ methods[current_method] = ""
81
+ method_start = true
82
+ indent_level = line.match(/^\s*/).to_s.length
83
+ elsif method_start && line.match(/^\s*end\s*$/) && line.match(/^\s*/).to_s.length == indent_level
84
+ method_start = false
85
+ current_method = nil
86
+ elsif method_start
87
+ methods[current_method] += line
88
+ end
89
+ end
90
+
91
+ methods
92
+ end
93
+
94
+ def calculate_cyclomatic_complexity(method_body)
95
+ complexity = 1 # base complexity
96
+
97
+ # Count control flow keywords
98
+ complexity += method_body.scan(/\b(if|unless|case|when|while|until|for|rescue|&&|\|\|)\b/).count
99
+ complexity += method_body.scan(/\?\s*:/).count # ternary operators
100
+
101
+ complexity
102
+ end
103
+
104
+ def calculate_max_nesting_depth(content)
105
+ max_depth = 0
106
+ current_depth = 0
107
+
108
+ content.each_line do |line|
109
+ indent = line.match(/^\s*/).to_s.length / 2 # assuming 2 spaces per indent
110
+
111
+ # Increase depth for control structures
112
+ if line.match?(/\b(if|unless|case|while|until|for|begin|def|class|module)\b/)
113
+ current_depth = [current_depth, indent + 1].max
114
+ max_depth = [max_depth, current_depth].max
115
+ end
116
+ end
117
+
118
+ max_depth
119
+ end
120
+
121
+ def extract_class_name(file)
122
+ relative_path = file.sub('app/', '').sub('.rb', '')
123
+ parts = relative_path.split('/')
124
+ class_parts = parts.map do |part|
125
+ part.split('_').map(&:capitalize).join
126
+ end
127
+ class_parts.join('::')
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails'
4
+
5
+ module RailsArchitect
6
+ class Railtie < Rails::Railtie
7
+ rake_tasks do
8
+ load File.expand_path('tasks/rails_architect.rake', __dir__)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsArchitect
4
+ class SolidAnalyzer
5
+ attr_reader :issues
6
+
7
+ def initialize
8
+ @issues = []
9
+ end
10
+
11
+ def analyze
12
+ check_single_responsibility
13
+ check_open_closed
14
+ check_liskov_substitution
15
+ check_interface_segregation
16
+ check_dependency_inversion
17
+ end
18
+
19
+ private
20
+
21
+ def add_issue(file, type, message)
22
+ @issues << { file: file, type: type, message: message }
23
+ end
24
+
25
+ def check_single_responsibility
26
+ # Check for classes with too many public methods (fat interfaces)
27
+ Dir.glob("app/models/**/*.rb").each do |file|
28
+ content = File.read(file)
29
+ public_methods = count_public_methods(content)
30
+ if public_methods > 10
31
+ class_name = extract_class_name(file)
32
+ add_issue(file, "solid_srp", "#{class_name} has #{public_methods} public methods, violates Single Responsibility Principle (fat model)")
33
+ end
34
+
35
+ # Check for models with callbacks doing business logic
36
+ callback_methods = content.scan(/after_create|before_save|after_save|before_destroy|after_destroy/).count
37
+ if callback_methods > 2
38
+ class_name = extract_class_name(file)
39
+ add_issue(file, "solid_srp", "#{class_name} has #{callback_methods} callbacks, consider moving to service objects or jobs")
40
+ end
41
+
42
+ # Check for models with complex business logic methods
43
+ complex_methods = content.scan(/def \w+.*\n.*\n.*\n.*\n.*end/m).count
44
+ if complex_methods > 0
45
+ class_name = extract_class_name(file)
46
+ add_issue(file, "solid_srp", "#{class_name} has complex methods, extract to service objects")
47
+ end
48
+ end
49
+
50
+ Dir.glob("app/controllers/**/*.rb").each do |file|
51
+ content = File.read(file)
52
+ public_methods = count_public_methods(content)
53
+ if public_methods > 8
54
+ class_name = extract_class_name(file)
55
+ add_issue(file, "solid_srp", "#{class_name} has #{public_methods} public actions, violates Single Responsibility Principle (fat controller)")
56
+ end
57
+
58
+ # Check for controllers doing business logic
59
+ if content.include?("UserMailer") || content.include?("NotificationService") || content.include?("ActivityLogger")
60
+ class_name = extract_class_name(file)
61
+ add_issue(file, "solid_srp", "#{class_name} is doing business logic, extract to service objects")
62
+ end
63
+ end
64
+ end
65
+
66
+ def check_open_closed
67
+ # Check for long inheritance chains
68
+ Dir.glob("app/models/**/*.rb").each do |file|
69
+ content = File.read(file)
70
+ inheritance_depth = count_inheritance_depth(content)
71
+ if inheritance_depth > 3
72
+ class_name = extract_class_name(file)
73
+ add_issue(file, "solid_ocp", "#{class_name} has inheritance depth of #{inheritance_depth}, consider composition over inheritance")
74
+ end
75
+ end
76
+
77
+ # Check for classes that would need modification for new features
78
+ Dir.glob("app/**/*.rb").each do |file|
79
+ content = File.read(file)
80
+ if content.include?("case ") && content.scan(/when /).count > 3
81
+ class_name = extract_class_name(file)
82
+ add_issue(file, "solid_ocp", "#{class_name} uses case statements that may need modification, consider polymorphism")
83
+ end
84
+ end
85
+ end
86
+
87
+ def check_liskov_substitution
88
+ # Check for STI models that might violate LSP
89
+ Dir.glob("app/models/**/*.rb").each do |file|
90
+ content = File.read(file)
91
+ if content.include?("self.inheritance_column") || content.match?(/class \w+ < \w+/)
92
+ class_name = extract_class_name(file)
93
+ # Look for methods that might not be implemented in subclasses
94
+ methods_with_bang = content.scan(/def \w+!/).map { |m| m.sub("def ", "").sub("!", "") }
95
+ if methods_with_bang.any?
96
+ add_issue(file, "solid_lsp", "#{class_name} uses inheritance/STI, ensure subclasses implement #{methods_with_bang.join(', ')} methods")
97
+ end
98
+ end
99
+ end
100
+
101
+ # Check for polymorphic associations that might violate contracts
102
+ Dir.glob("app/models/**/*.rb").each do |file|
103
+ content = File.read(file)
104
+ if content.include?("belongs_to :") && content.include?("polymorphic: true")
105
+ class_name = extract_class_name(file)
106
+ add_issue(file, "solid_lsp", "#{class_name} uses polymorphic associations, ensure all associated classes honor the same interface")
107
+ end
108
+ end
109
+ end
110
+
111
+ def check_interface_segregation
112
+ # Check for controllers with many dependencies (includes/modules)
113
+ Dir.glob("app/controllers/**/*.rb").each do |file|
114
+ content = File.read(file)
115
+ includes_count = content.scan(/\binclude\b|\bextend\b|\bprepend\b/).count
116
+ if includes_count > 3
117
+ class_name = extract_class_name(file)
118
+ add_issue(file, "solid_isp", "#{class_name} includes #{includes_count} modules, consider interface segregation")
119
+ end
120
+ end
121
+
122
+ # Check for heavy ActiveSupport::Concern usage
123
+ Dir.glob("app/**/*.rb").each do |file|
124
+ content = File.read(file)
125
+ concern_methods = content.scan(/def \w+/).count
126
+ if content.include?("ActiveSupport::Concern") && concern_methods > 5
127
+ class_name = extract_class_name(file)
128
+ add_issue(file, "solid_isp", "#{class_name} concern has #{concern_methods} methods, consider splitting into smaller concerns")
129
+ end
130
+ end
131
+
132
+ # Check for god services with many methods
133
+ Dir.glob("app/services/**/*.rb").each do |file|
134
+ content = File.read(file)
135
+ public_methods = count_public_methods(content)
136
+ if public_methods > 10
137
+ class_name = extract_class_name(file)
138
+ add_issue(file, "solid_isp", "#{class_name} service has #{public_methods} methods, split into smaller focused services")
139
+ end
140
+ end
141
+ end
142
+
143
+ def check_dependency_inversion
144
+ # Check for direct instantiation of classes (new keyword)
145
+ Dir.glob("app/**/*.rb").each do |file|
146
+ content = File.read(file)
147
+ new_calls = content.scan(/\b\w+\.new\b/).count
148
+ if new_calls > 2
149
+ class_name = extract_class_name(file)
150
+ add_issue(file, "solid_dip", "#{class_name} has #{new_calls} direct instantiations, consider dependency injection")
151
+ end
152
+ end
153
+
154
+ # Check for hard-coded external service dependencies
155
+ Dir.glob("app/**/*.rb").each do |file|
156
+ content = File.read(file)
157
+ if content.include?("TwilioClient.new") || content.include?("Stripe::") || content.include?("HTTParty.get")
158
+ class_name = extract_class_name(file)
159
+ add_issue(file, "solid_dip", "#{class_name} has hard-coded external dependencies, inject abstractions instead")
160
+ end
161
+ end
162
+
163
+ # Check for model callbacks triggering network calls
164
+ Dir.glob("app/models/**/*.rb").each do |file|
165
+ content = File.read(file)
166
+ callback_content = content.split(/\b(private|protected)\b/).first
167
+ if callback_content.include?("deliver") || callback_content.include?("HTTP") || callback_content.include?("Net::")
168
+ class_name = extract_class_name(file)
169
+ add_issue(file, "solid_dip", "#{class_name} has network calls in callbacks, move to background jobs")
170
+ end
171
+ end
172
+ end
173
+
174
+ def count_public_methods(content)
175
+ public_content = content.split(/\b(private|protected)\b/).first
176
+ public_content.scan(/def \w+/).count
177
+ end
178
+
179
+ def count_inheritance_depth(content)
180
+ inheritance_matches = content.scan(/class \w+ < (\w+(?:::\w+)*)/)
181
+ return 0 if inheritance_matches.empty?
182
+
183
+ # Simple depth calculation - in real app would need to resolve inheritance tree
184
+ inheritance_matches.first.first.split('::').count
185
+ end
186
+
187
+ def extract_class_name(file)
188
+ relative_path = file.sub('app/', '').sub('.rb', '')
189
+ parts = relative_path.split('/')
190
+ class_parts = parts.map do |part|
191
+ part.split('_').map(&:capitalize).join
192
+ end
193
+ class_parts.join('::')
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_architect'
4
+
5
+ namespace :rails_architect do
6
+ desc "Check SOLID principles"
7
+ task check_solid: :environment do
8
+ analyzer = RailsArchitect::SolidAnalyzer.new
9
+ analyzer.analyze
10
+ issues = analyzer.issues
11
+ puts "SOLID violations: #{issues.count}"
12
+ issues.each { |issue| puts "- #{issue[:message]}" }
13
+ exit 1 if issues.any?
14
+ end
15
+
16
+ desc "Check KISS principle (Keep It Simple)"
17
+ task check_kiss: :environment do
18
+ analyzer = RailsArchitect::KissAnalyzer.new
19
+ analyzer.analyze
20
+ issues = analyzer.issues
21
+ puts "KISS violations: #{issues.count}"
22
+ issues.each { |issue| puts "- #{issue[:message]}" }
23
+ exit 1 if issues.any?
24
+ end
25
+
26
+ desc "Check DRY principle (Don't Repeat Yourself)"
27
+ task check_dry: :environment do
28
+ analyzer = RailsArchitect::DryAnalyzer.new
29
+ analyzer.analyze
30
+ issues = analyzer.issues
31
+ puts "DRY violations: #{issues.count}"
32
+ issues.each { |issue| puts "- #{issue[:message]}" }
33
+ exit 1 if issues.any?
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsArchitect
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_architect/version"
4
+ require "rails_architect/solid_analyzer"
5
+ require "rails_architect/kiss_analyzer"
6
+ require "rails_architect/dry_analyzer"
7
+ require "rails_architect/railtie"
8
+
9
+ module RailsArchitect
10
+ def self.analyze
11
+ all_issues = []
12
+
13
+ # Run SOLID analysis
14
+ solid_analyzer = SolidAnalyzer.new
15
+ solid_analyzer.analyze
16
+ all_issues.concat(solid_analyzer.issues)
17
+
18
+ # Run KISS analysis
19
+ kiss_analyzer = KissAnalyzer.new
20
+ kiss_analyzer.analyze
21
+ all_issues.concat(kiss_analyzer.issues)
22
+
23
+ # Run DRY analysis
24
+ dry_analyzer = DryAnalyzer.new
25
+ dry_analyzer.analyze
26
+ all_issues.concat(dry_analyzer.issues)
27
+
28
+ all_issues
29
+ end
30
+ end
@@ -0,0 +1,72 @@
1
+ # Test Rails Application
2
+
3
+ This is a sample Rails application designed to demonstrate various code quality violations that Rails Architect can detect.
4
+
5
+ ## Purpose
6
+
7
+ This app contains intentional violations of software design principles to test the analyzers.
8
+
9
+ ## Violations by File
10
+
11
+ ### Models
12
+
13
+ #### `app/models/user.rb`
14
+ - **SOLID Violation**: Fat Model with 15+ instance methods
15
+ - Violates Single Responsibility Principle
16
+ - Should be split into separate concerns/services
17
+ - Exceeds threshold of 10 methods per model
18
+
19
+ #### `app/models/report_generator.rb`
20
+ - **KISS Violation**: Overly complex `generate_complex_report` method
21
+ - Deeply nested conditionals (if/elsif chains)
22
+ - Multiple loop types (while, until, for)
23
+ - Complex case statements
24
+ - Exceeds complexity threshold of 10
25
+
26
+ - **DRY Violation**: Duplicated calculation logic
27
+ - `calculate_user_score_v1` and `calculate_user_score_v2` contain identical code
28
+ - Should be refactored to a single method
29
+
30
+ #### `app/models/post.rb`
31
+ - Example of a well-structured model (no violations)
32
+
33
+ ### Controllers
34
+
35
+ #### `app/controllers/users_controller.rb`
36
+ - **SOLID Violation**: Controller doing too much
37
+ - Business logic in controller actions (calculating stats, reputation)
38
+ - Multiple responsibilities (data fetching, business logic, presentation)
39
+ - Should use service objects
40
+
41
+ - **KISS Violation**: Complex action logic
42
+ - Nested conditionals for permissions
43
+ - Multiple concerns mixed together in `create` action
44
+
45
+ #### `app/controllers/posts_controller.rb`
46
+ - **DRY Violation**: Repeated query and filtering patterns
47
+ - Same filtering logic in `index` and `recent` actions
48
+ - Repeated published/draft post selection
49
+ - Should be extracted to scopes or service methods
50
+
51
+ ## Running Checks
52
+
53
+ From the gem root directory:
54
+
55
+ ```bash
56
+ # Check SOLID principles
57
+ bundle exec rake rails_architect:check_solid
58
+
59
+ # Check KISS principle
60
+ bundle exec rake rails_architect:check_kiss
61
+
62
+ # Check DRY principle
63
+ bundle exec rake rails_architect:check_dry
64
+ ```
65
+
66
+ ## Expected Results
67
+
68
+ When running the analyzers, you should see violations detected in:
69
+ - User model (too many methods)
70
+ - ReportGenerator (complex method, duplicated code)
71
+ - UsersController (fat controller)
72
+ - PostsController (code duplication)
@@ -0,0 +1,54 @@
1
+ class PostsController < ApplicationController
2
+ # DRY Violation: Repeated query patterns
3
+ def index
4
+ @posts = Post.all.includes(:user)
5
+
6
+ # Potential N+1 query - accessing user.name for each post
7
+ @posts.each do |post|
8
+ puts "Author: #{post.user.name}"
9
+ puts "Comments: #{post.comments.count}"
10
+ end
11
+
12
+ # Repeated filtering logic (DRY violation)
13
+ @published_posts = @posts.select { |p| p.published? }
14
+ @draft_posts = @posts.reject { |p| p.published? }
15
+ end
16
+
17
+ def show
18
+ @post = Post.find(params[:id])
19
+ @comments = @post.comments.includes(:user)
20
+
21
+ # Another potential N+1
22
+ @comments.each do |comment|
23
+ puts comment.user.email
24
+ end
25
+ end
26
+
27
+ # DRY Violation: Similar logic to index
28
+ def recent
29
+ @posts = Post.all.includes(:user)
30
+
31
+ # Same filtering logic as in index
32
+ @published_posts = @posts.select { |p| p.published? }
33
+ @draft_posts = @posts.reject { |p| p.published? }
34
+
35
+ render :index
36
+ end
37
+
38
+ def create
39
+ @post = current_user.posts.build(post_params)
40
+
41
+ if @post.save
42
+ redirect_to @post
43
+ else
44
+ render :new
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def post_params
51
+ params.require(:post).permit(:title, :content, :published)
52
+ end
53
+ end
54
+ ```
@@ -0,0 +1,104 @@
1
+ class UsersController < ApplicationController
2
+ before_action :set_user, only: [:show, :edit, :update, :destroy]
3
+ before_action :authenticate_user!
4
+ before_action :authorize_admin, only: [:index, :destroy]
5
+
6
+ # SOLID Violation: Controller action doing too much (violates Single Responsibility)
7
+ def index
8
+ @users = User.all
9
+ @users = @users.where(organization_id: current_user.organization_id) unless current_user.admin?
10
+ @users = @users.page(params[:page]).per(20)
11
+
12
+ # Doing business logic in controller
13
+ @user_stats = @users.map do |user|
14
+ {
15
+ id: user.id,
16
+ posts_count: user.posts.count,
17
+ comments_count: user.comments.count,
18
+ reputation: user.posts.count * 5 + user.comments.count * 2
19
+ }
20
+ end
21
+ end
22
+
23
+ # KISS Violation: Complex controller action
24
+ def show
25
+ @posts = @user.posts.includes(:comments).limit(10)
26
+ @recent_comments = @user.comments.order(created_at: :desc).limit(5)
27
+
28
+ # Complex conditional logic in controller
29
+ if @user.admin?
30
+ @permissions = ['all']
31
+ elsif @user.moderator?
32
+ @permissions = ['read', 'write', 'moderate']
33
+ else
34
+ @permissions = ['read']
35
+ end
36
+ end
37
+
38
+ def new
39
+ @user = User.new
40
+ @organizations = Organization.all
41
+ end
42
+
43
+ def create
44
+ @user = User.new(user_params)
45
+ @user.organization = current_user.organization unless current_user.admin?
46
+
47
+ if @user.save
48
+ # Should be in a service object
49
+ UserMailer.welcome(@user).deliver_later
50
+ NotificationService.notify_admins(@user)
51
+ ActivityLogger.log_user_creation(@user)
52
+
53
+ redirect_to @user, notice: 'User was successfully created.'
54
+ else
55
+ @organizations = Organization.all
56
+ render :new
57
+ end
58
+ end
59
+
60
+ def edit
61
+ @organizations = Organization.all
62
+ end
63
+
64
+ def update
65
+ if @user.update(user_params)
66
+ redirect_to @user, notice: 'User was successfully updated.'
67
+ else
68
+ @organizations = Organization.all
69
+ render :edit
70
+ end
71
+ end
72
+
73
+ def destroy
74
+ @user.destroy
75
+ redirect_to users_url, notice: 'User was successfully destroyed.'
76
+ end
77
+
78
+ def activate
79
+ @user = User.find(params[:id])
80
+ @user.activate
81
+ redirect_to @user, notice: 'User activated.'
82
+ end
83
+
84
+ def deactivate
85
+ @user = User.find(params[:id])
86
+ @user.deactivate
87
+ redirect_to @user, notice: 'User deactivated.'
88
+ end
89
+
90
+ private
91
+
92
+ def set_user
93
+ @user = User.find(params[:id])
94
+ end
95
+
96
+ def user_params
97
+ params.require(:user).permit(:name, :email, :role, :organization_id)
98
+ end
99
+
100
+ def authorize_admin
101
+ redirect_to root_path unless current_user.admin?
102
+ end
103
+ end
104
+ ```
@@ -0,0 +1,34 @@
1
+ class Post < ApplicationRecord
2
+ belongs_to :user
3
+ has_many :comments
4
+
5
+ validates :title, presence: true
6
+ validates :content, presence: true
7
+
8
+ scope :published, -> { where(published: true) }
9
+ scope :recent, -> { order(created_at: :desc) }
10
+
11
+ def author_name
12
+ user.name
13
+ end
14
+
15
+ def comment_count
16
+ comments.count
17
+ end
18
+
19
+ def published_comments
20
+ comments.where(published: true)
21
+ end
22
+
23
+ def featured?
24
+ featured
25
+ end
26
+
27
+ def publish
28
+ update(published: true, published_at: Time.current)
29
+ end
30
+
31
+ def unpublish
32
+ update(published: false, published_at: nil)
33
+ end
34
+ end