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.
- checksums.yaml +7 -0
- data/.github/workflows/tests.yml +52 -0
- data/.gitignore +39 -0
- data/.rubocop.yml +51 -0
- data/CHANGELOG.md +66 -0
- data/CODE_OF_CONDUCT.md +41 -0
- data/CONTRIBUTING.md +534 -0
- data/EXAMPLE_GEMFILE +11 -0
- data/Gemfile +12 -0
- data/INDEX.md +192 -0
- data/LICENSE.md +21 -0
- data/README.md +270 -0
- data/Rakefile +15 -0
- data/exe/rails_architect +6 -0
- data/lib/rails_architect/analyzers/architecture_analyzer.rb +148 -0
- data/lib/rails_architect/analyzers/bdd_analyzer.rb +160 -0
- data/lib/rails_architect/analyzers/solid_analyzer.rb +239 -0
- data/lib/rails_architect/analyzers/tdd_analyzer.rb +138 -0
- data/lib/rails_architect/cli.rb +83 -0
- data/lib/rails_architect/reporters/report_generator.rb +212 -0
- data/lib/rails_architect/version.rb +5 -0
- data/lib/rails_architect.rb +53 -0
- data/rails_architect.gemspec +34 -0
- metadata +136 -0
|
@@ -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
|