rails_architect_analyzer 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,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsArchitect
4
+ module Analyzers
5
+ # Analyzes behavior-driven development practices in Rails projects
6
+ class BddAnalyzer
7
+ attr_reader :project_path
8
+
9
+ def initialize(project_path = Rails.root)
10
+ @project_path = project_path
11
+ end
12
+
13
+ def analyze
14
+ {
15
+ has_cucumber: cucumber?,
16
+ has_rspec: rspec?,
17
+ feature_files_count: count_feature_files,
18
+ step_definitions_count: count_step_definitions,
19
+ score: calculate_bdd_score,
20
+ suggestions: generate_suggestions,
21
+ practices: check_bdd_practices
22
+ }
23
+ end
24
+
25
+ private
26
+
27
+ def cucumber?
28
+ gemfile_path = File.join(project_path, "Gemfile")
29
+ return false unless File.exist?(gemfile_path)
30
+
31
+ File.read(gemfile_path).include?("cucumber")
32
+ end
33
+
34
+ def rspec?
35
+ gemfile_path = File.join(project_path, "Gemfile")
36
+ return false unless File.exist?(gemfile_path)
37
+
38
+ File.read(gemfile_path).include?("rspec")
39
+ end
40
+
41
+ def count_feature_files
42
+ features_path = File.join(project_path, "features")
43
+ return 0 unless File.directory?(features_path)
44
+
45
+ Dir.glob(File.join(features_path, "**/*.feature")).count
46
+ end
47
+
48
+ def count_step_definitions
49
+ steps_path = File.join(project_path, "features/step_definitions")
50
+ return 0 unless File.directory?(steps_path)
51
+
52
+ Dir.glob(File.join(steps_path, "**/*.rb")).count
53
+ end
54
+
55
+ def calculate_bdd_score
56
+ features = count_feature_files
57
+ count_step_definitions
58
+
59
+ case features
60
+ when 0
61
+ { score: 0, rating: "❌ No BDD implemented", color: :red }
62
+ when 1...5
63
+ { score: 25, rating: "⚠️ Minimal BDD coverage", color: :yellow }
64
+ when 5...15
65
+ { score: 50, rating: "✅ Some BDD coverage", color: :light_yellow }
66
+ else
67
+ { score: 100, rating: "🎉 Strong BDD practices", color: :light_green }
68
+ end
69
+ end
70
+
71
+ def check_bdd_practices
72
+ {
73
+ user_stories: user_stories?,
74
+ readable_scenarios: check_readable_scenarios,
75
+ step_reusability: analyze_step_reusability,
76
+ integration_tests: check_integration_tests
77
+ }
78
+ end
79
+
80
+ def user_stories?
81
+ features_path = File.join(project_path, "features")
82
+ return false unless File.directory?(features_path)
83
+
84
+ Dir.glob(File.join(features_path, "**/*.feature")).any? do |file|
85
+ content = File.read(file)
86
+ content.include?("Feature:") && (content.include?("As a") || content.include?("Scenario"))
87
+ end
88
+ end
89
+
90
+ def check_readable_scenarios
91
+ features_path = File.join(project_path, "features")
92
+ return { present: false, count: 0 } unless File.directory?(features_path)
93
+
94
+ readable = Dir.glob(File.join(features_path, "**/*.feature")).count do |file|
95
+ content = File.read(file)
96
+ content.match?(/Given|When|Then/)
97
+ end
98
+
99
+ { present: readable.positive?, count: readable }
100
+ end
101
+
102
+ def analyze_step_reusability
103
+ steps_path = File.join(project_path, "features/step_definitions")
104
+ return { score: 0, recommendation: "Create step definitions" } unless File.directory?(steps_path)
105
+
106
+ step_files = Dir.glob(File.join(steps_path, "*.rb"))
107
+ return { score: 0, recommendation: "Create step definitions" } if step_files.empty?
108
+
109
+ avg_lines = step_files.map { |f| File.readlines(f).count }.sum / step_files.count
110
+
111
+ {
112
+ score: avg_lines,
113
+ recommendation: avg_lines > 200 ? "Refactor steps - they're getting too large" : "Steps are well-organized"
114
+ }
115
+ end
116
+
117
+ def check_integration_tests
118
+ {
119
+ request_specs: count_request_specs,
120
+ feature_tests: count_feature_files,
121
+ integration_test_files: count_integration_test_files
122
+ }
123
+ end
124
+
125
+ def count_request_specs
126
+ specs_path = File.join(project_path, "spec/requests")
127
+ return 0 unless File.directory?(specs_path)
128
+
129
+ Dir.glob(File.join(specs_path, "**/*_spec.rb")).count
130
+ end
131
+
132
+ def count_integration_test_files
133
+ integration_path = File.join(project_path, "test/integration")
134
+ return 0 unless File.directory?(integration_path)
135
+
136
+ Dir.glob(File.join(integration_path, "**/*_test.rb")).count
137
+ end
138
+
139
+ def generate_suggestions
140
+ suggestions = []
141
+
142
+ suggestions << "Consider adding Cucumber for BDD with human-readable scenarios" unless cucumber?
143
+
144
+ if count_feature_files.zero? && cucumber?
145
+ suggestions << "No feature files found. Start writing user stories in features/"
146
+ end
147
+
148
+ suggestions << "Consider using RSpec for more expressive tests" unless rspec?
149
+
150
+ unless user_stories?
151
+ suggestions << "Write user stories using 'As a... I want... So that...' format in feature files"
152
+ end
153
+
154
+ suggestions << "Structure your scenarios using Given/When/Then format" unless check_readable_scenarios[:present]
155
+
156
+ suggestions
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsArchitect
4
+ module Analyzers
5
+ # Evaluates SOLID principles adherence in Rails projects
6
+ class SolidAnalyzer
7
+ attr_reader :project_path
8
+
9
+ def initialize(project_path = Rails.root)
10
+ @project_path = project_path
11
+ end
12
+
13
+ def analyze
14
+ {
15
+ score: calculate_solid_score,
16
+ single_responsibility: analyze_single_responsibility,
17
+ open_closed: analyze_open_closed,
18
+ liskov_substitution: analyze_liskov,
19
+ interface_segregation: analyze_interface_segregation,
20
+ dependency_inversion: analyze_dependency_inversion,
21
+ suggestions: generate_suggestions
22
+ }
23
+ end
24
+
25
+ private
26
+
27
+ def analyze_single_responsibility
28
+ {
29
+ description: "A class should have only one reason to change",
30
+ issues: detect_srp_violations,
31
+ status: detect_srp_violations.empty? ? "✅ Good" : "⚠️ Violations found"
32
+ }
33
+ end
34
+
35
+ def analyze_open_closed
36
+ {
37
+ description: "Classes should be open for extension, closed for modification",
38
+ has_concerns: concerns?,
39
+ has_modules: modules?,
40
+ has_inheritance: inheritance?,
41
+ status: concerns? || inheritance? ? "✅ Some patterns found" : "⚠️ Consider using concerns or inheritance"
42
+ }
43
+ end
44
+
45
+ def analyze_liskov
46
+ chains = analyze_inheritance_chains
47
+ {
48
+ description: "Objects should be replaceable by their subtypes without breaking",
49
+ inheritance_chains: chains,
50
+ concerns_usage: count_concerns,
51
+ status: chains < 3 ? "✅ Good" : "⚠️ Deep inheritance chains detected"
52
+ }
53
+ end
54
+
55
+ def analyze_interface_segregation
56
+ {
57
+ description: "Clients should not depend on interfaces they don't use",
58
+ fat_modules: detect_fat_modules,
59
+ large_classes: detect_large_classes,
60
+ status: (detect_fat_modules + detect_large_classes).empty? ? "✅ Good" : "⚠️ Violations found"
61
+ }
62
+ end
63
+
64
+ def analyze_dependency_inversion
65
+ {
66
+ description: "Depend on abstractions, not concretions",
67
+ service_layer: services?,
68
+ dependency_injection: detect_dependency_injection,
69
+ status: services? ? "✅ Service layer detected" : "⚠️ Consider implementing service layer"
70
+ }
71
+ end
72
+
73
+ def calculate_solid_score
74
+ srp = detect_srp_violations.empty? ? 20 : 10
75
+ ocp = concerns? || inheritance? ? 20 : 10
76
+ lsp = analyze_inheritance_chains < 3 ? 20 : 10
77
+ isp = (detect_fat_modules + detect_large_classes).empty? ? 20 : 10
78
+ dip = services? ? 20 : 10
79
+
80
+ total = srp + ocp + lsp + isp + dip
81
+ { score: total, rating: rating_from_score(total), color: color_from_score(total) }
82
+ end
83
+
84
+ def rating_from_score(score)
85
+ case score
86
+ when 0...30
87
+ "❌ Poor"
88
+ when 30...60
89
+ "⚠️ Fair"
90
+ when 60...80
91
+ "✅ Good"
92
+ else
93
+ "🎉 Excellent"
94
+ end
95
+ end
96
+
97
+ def color_from_score(score)
98
+ case score
99
+ when 0...30
100
+ :red
101
+ when 30...60
102
+ :yellow
103
+ when 60...80
104
+ :light_green
105
+ else
106
+ :green
107
+ end
108
+ end
109
+
110
+ def detect_srp_violations
111
+ violations = []
112
+ models_path = File.join(project_path, "app/models")
113
+ return violations unless File.directory?(models_path)
114
+
115
+ Dir.glob(File.join(models_path, "*.rb")).each do |file|
116
+ content = File.read(file)
117
+ # Simple heuristic: check for many associations and methods
118
+ associations = content.scan(/has_many|belongs_to|has_one/).count
119
+ methods = content.scan("def ").count
120
+
121
+ violations << File.basename(file, ".rb") if associations > 5 && methods > 10
122
+ end
123
+
124
+ violations
125
+ end
126
+
127
+ def concerns?
128
+ concerns_path = File.join(project_path, "app/concerns")
129
+ File.directory?(concerns_path) && !Dir.glob(File.join(concerns_path, "*.rb")).empty?
130
+ end
131
+
132
+ def modules?
133
+ lib_path = File.join(project_path, "lib")
134
+ return false unless File.directory?(lib_path)
135
+
136
+ Dir.glob(File.join(lib_path, "**/*.rb")).any? { |f| File.read(f).include?("module ") }
137
+ end
138
+
139
+ def inheritance?
140
+ app_path = File.join(project_path, "app")
141
+ return false unless File.directory?(app_path)
142
+
143
+ Dir.glob(File.join(app_path, "**/*.rb")).any? { |f| File.read(f).match?(/class \w+ </) }
144
+ end
145
+
146
+ def analyze_inheritance_chains
147
+ app_path = File.join(project_path, "app")
148
+ return 0 unless File.directory?(app_path)
149
+
150
+ max_depth = 0
151
+ Dir.glob(File.join(app_path, "**/*.rb")).each do |file|
152
+ content = File.read(file)
153
+ depth = content.scan(/class \w+ </).count
154
+ max_depth = depth if depth > max_depth
155
+ end
156
+
157
+ max_depth
158
+ end
159
+
160
+ def count_concerns
161
+ concerns_path = File.join(project_path, "app/concerns")
162
+ return 0 unless File.directory?(concerns_path)
163
+
164
+ Dir.glob(File.join(concerns_path, "*.rb")).count
165
+ end
166
+
167
+ def detect_fat_modules
168
+ modules = []
169
+ lib_path = File.join(project_path, "lib")
170
+ return modules unless File.directory?(lib_path)
171
+
172
+ Dir.glob(File.join(lib_path, "**/*.rb")).each do |file|
173
+ lines = File.readlines(file).count
174
+ modules << File.basename(file, ".rb") if lines > 300
175
+ end
176
+
177
+ modules
178
+ end
179
+
180
+ def detect_large_classes
181
+ classes = []
182
+ app_path = File.join(project_path, "app")
183
+ return classes unless File.directory?(app_path)
184
+
185
+ Dir.glob(File.join(app_path, "**/*.rb")).each do |file|
186
+ lines = File.readlines(file).count
187
+ classes << File.basename(file, ".rb") if lines > 150
188
+ end
189
+
190
+ classes
191
+ end
192
+
193
+ def services?
194
+ services_path = File.join(project_path, "app/services")
195
+ File.directory?(services_path) && !Dir.glob(File.join(services_path, "*.rb")).empty?
196
+ end
197
+
198
+ def detect_dependency_injection
199
+ app_path = File.join(project_path, "app")
200
+ return 0 unless File.directory?(app_path)
201
+
202
+ count = 0
203
+ Dir.glob(File.join(app_path, "**/*.rb")).each do |file|
204
+ content = File.read(file)
205
+ # Look for initialize methods with parameters
206
+ count += 1 if content.match?(/def initialize\([^)]+\)/)
207
+ end
208
+
209
+ count
210
+ end
211
+
212
+ def generate_suggestions
213
+ suggestions = []
214
+
215
+ unless detect_srp_violations.empty?
216
+ suggestions << "⚠️ Classes with mixed responsibilities detected: #{detect_srp_violations.join(', ')}"
217
+ suggestions << "Extract logic into service objects or concerns"
218
+ end
219
+
220
+ suggestions << "Consider creating app/concerns for shared behavior" unless concerns?
221
+
222
+ suggestions << "Implement a service layer (app/services) for complex business logic" unless services?
223
+
224
+ if analyze_inheritance_chains >= 3
225
+ suggestions << "⚠️ Deep inheritance chains detected. Consider using composition or concerns"
226
+ end
227
+
228
+ unless detect_large_classes.empty?
229
+ suggestions << "⚠️ Large classes detected: #{detect_large_classes.first(3).join(', ')}"
230
+ suggestions << "Break them down using SRP (Single Responsibility Principle)"
231
+ end
232
+
233
+ suggestions << "Use dependency injection to reduce tight coupling" if detect_dependency_injection < 10
234
+
235
+ suggestions
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsArchitect
4
+ module Analyzers
5
+ # Analyzes test-driven development coverage and practices
6
+ class TddAnalyzer
7
+ attr_reader :project_path
8
+
9
+ def initialize(project_path = Rails.root)
10
+ @project_path = project_path
11
+ end
12
+
13
+ def analyze
14
+ {
15
+ score: calculate_coverage_score,
16
+ test_files: count_test_files,
17
+ spec_files: count_spec_files,
18
+ coverage_percentage: estimate_coverage,
19
+ suggestions: generate_suggestions,
20
+ test_structure: analyze_test_structure
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def count_test_files
27
+ test_path = File.join(project_path, "test")
28
+ return 0 unless File.directory?(test_path)
29
+
30
+ Dir.glob(File.join(test_path, "**/*_test.rb")).count
31
+ end
32
+
33
+ def count_spec_files
34
+ spec_path = File.join(project_path, "spec")
35
+ return 0 unless File.directory?(spec_path)
36
+
37
+ Dir.glob(File.join(spec_path, "**/*_spec.rb")).count
38
+ end
39
+
40
+ def estimate_coverage
41
+ spec_files = count_spec_files
42
+ test_files = count_test_files
43
+ code_files = count_code_files
44
+
45
+ return 0 if code_files.zero?
46
+
47
+ total_tests = spec_files + test_files
48
+ ((total_tests.to_f / code_files) * 100).round(2)
49
+ end
50
+
51
+ def count_code_files
52
+ app_path = File.join(project_path, "app")
53
+ return 0 unless File.directory?(app_path)
54
+
55
+ Dir.glob(File.join(app_path, "**/*.rb")).count
56
+ end
57
+
58
+ def calculate_coverage_score
59
+ coverage = estimate_coverage
60
+ case coverage
61
+ when 0...20
62
+ { score: coverage, rating: "❌ Poor", color: :red }
63
+ when 20...50
64
+ { score: coverage, rating: "⚠️ Fair", color: :yellow }
65
+ when 50...80
66
+ { score: coverage, rating: "✅ Good", color: :green }
67
+ else
68
+ { score: coverage, rating: "🎉 Excellent", color: :light_green }
69
+ end
70
+ end
71
+
72
+ def analyze_test_structure
73
+ {
74
+ models: analyze_test_type("models"),
75
+ controllers: analyze_test_type("controllers"),
76
+ services: analyze_test_type("services"),
77
+ helpers: analyze_test_type("helpers"),
78
+ requests: analyze_test_type("requests")
79
+ }
80
+ end
81
+
82
+ def analyze_test_type(type)
83
+ spec_path = File.join(project_path, "spec", type)
84
+ test_path = File.join(project_path, "test", type)
85
+
86
+ spec_count = File.directory?(spec_path) ? Dir.glob(File.join(spec_path, "**/*.rb")).count : 0
87
+ test_count = File.directory?(test_path) ? Dir.glob(File.join(test_path, "**/*.rb")).count : 0
88
+
89
+ {
90
+ spec: spec_count,
91
+ test: test_count,
92
+ total: spec_count + test_count
93
+ }
94
+ end
95
+
96
+ def generate_suggestions
97
+ suggestions = []
98
+
99
+ if count_spec_files.zero? && count_test_files.zero?
100
+ suggestions << "❌ No tests found! Start by creating specs using RSpec or Minitest"
101
+ end
102
+
103
+ coverage = estimate_coverage
104
+ if coverage < 50 && coverage.positive?
105
+ suggestions << "⚠️ Test coverage is low (#{coverage.round(2)}%). Aim for at least 80%"
106
+ end
107
+
108
+ if analyze_test_structure[:models][:total].zero?
109
+ suggestions << "Add model specs/tests to ensure data validation logic"
110
+ end
111
+
112
+ if analyze_test_structure[:controllers][:total].zero?
113
+ suggestions << "Add controller specs/tests for request/response handling"
114
+ end
115
+
116
+ if analyze_test_structure[:services][:total].zero? && services?
117
+ suggestions << "Create tests for service objects"
118
+ end
119
+
120
+ suggestions << "Use factories (FactoryBot) for test data creation" if using_minitest_only?
121
+
122
+ suggestions
123
+ end
124
+
125
+ def services?
126
+ services_path = File.join(project_path, "app/services")
127
+ File.directory?(services_path) && !Dir.glob(File.join(services_path, "*.rb")).empty?
128
+ end
129
+
130
+ def using_minitest_only?
131
+ spec_files = count_spec_files
132
+ has_gemfile = File.exist?(File.join(project_path, "Gemfile"))
133
+
134
+ spec_files.zero? && has_gemfile
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "colorize"
5
+
6
+ module RailsArchitect
7
+ # Command-line interface for Rails Architect analysis tool
8
+ class CLI < Thor
9
+ desc "analyze [PROJECT_PATH]", "Analyze a Rails project for architecture, TDD, BDD, and SOLID principles"
10
+ option :json, type: :boolean, desc: "Output results as JSON"
11
+ option :output, type: :string, desc: "Save report to a file"
12
+
13
+ def analyze(project_path = nil)
14
+ project_path ||= Dir.pwd
15
+
16
+ unless File.directory?(project_path)
17
+ puts "❌ Project path does not exist: #{project_path}".colorize(:red)
18
+ exit 1
19
+ end
20
+
21
+ results = RailsArchitect::Core.new(project_path).analyze
22
+
23
+ if options[:json]
24
+ output = RailsArchitect::Reporters::ReportGenerator.new(results).to_json
25
+ if options[:output]
26
+ File.write(options[:output], output)
27
+ puts "✅ JSON report saved to: #{options[:output]}".colorize(:light_green)
28
+ else
29
+ puts output
30
+ end
31
+ elsif options[:output]
32
+ File.open(options[:output], "w") do |f|
33
+ # Redirect output to file
34
+ original_stdout = $stdout
35
+ $stdout = f
36
+ RailsArchitect::Reporters::ReportGenerator.new(results).generate
37
+ $stdout = original_stdout
38
+ end
39
+ puts "✅ Report saved to: #{options[:output]}".colorize(:light_green)
40
+ else
41
+ RailsArchitect::Reporters::ReportGenerator.new(results).generate
42
+ end
43
+ end
44
+
45
+ desc "suggest [PROJECT_PATH]", "Get architecture suggestions for your Rails project"
46
+ def suggest(project_path = nil)
47
+ project_path ||= Dir.pwd
48
+
49
+ unless File.directory?(project_path)
50
+ puts "❌ Project path does not exist: #{project_path}".colorize(:red)
51
+ exit 1
52
+ end
53
+
54
+ results = RailsArchitect::Core.new(project_path).analyze
55
+
56
+ puts "\n#{'=' * 80}"
57
+ puts "🎯 ARCHITECTURE SUGGESTIONS".colorize(:blue).bold
58
+ puts "#{'=' * 80}\n"
59
+
60
+ suggestions = (
61
+ results[:architecture][:suggestions] +
62
+ results[:tdd][:suggestions] +
63
+ results[:bdd][:suggestions] +
64
+ results[:solid][:suggestions]
65
+ ).uniq
66
+
67
+ if suggestions.any?
68
+ suggestions.each_with_index do |suggestion, index|
69
+ puts "#{index + 1}. #{suggestion}"
70
+ end
71
+ else
72
+ puts "✅ Your project is well-structured!".colorize(:light_green)
73
+ end
74
+
75
+ puts "\n#{'=' * 80}\n"
76
+ end
77
+
78
+ desc "version", "Show the version of rails_architect"
79
+ def version
80
+ puts "Rails Architect #{RailsArchitect::VERSION}"
81
+ end
82
+ end
83
+ end