rails_code_health 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1fcf270392e52c8b6ab9783a9ed2ffaa133825210ff18c0c4ec124ce8b8d2b7f
4
- data.tar.gz: 6ddc72d1451b604e7df94e655e22b225647842dcbd0a00baec0618d4e3b1c823
3
+ metadata.gz: aaafc84bb8cf4bad9931fb39ef97d3a1bf76a61bc4e2289191df870f10a33e1e
4
+ data.tar.gz: ad77a703a06b6eee946756f7645ef76de9c943fcadd192c27a425bd37f3027fd
5
5
  SHA512:
6
- metadata.gz: 416a30e4fd42da4f2f8fe100e349c611c009d5468710974c59d945036145ce1c0df543ca1a8f51f3bf53868cd6ce9472e4be3457dcdaca3d5de12258ef16ed77
7
- data.tar.gz: 44e261b45a963e81c5d3ace4be6b360ff8c052ba39dd4b0e3fa56789c145673e3b51d5359ebed08a2f7eb50214044031f0f6185760e3ac06072531be25659769
6
+ metadata.gz: 832e4d0437e9e92e121da7a890f3451ee5f37047a586db713edfb07265ab55e5f8c0f221b21f9c4eccb974b7e4b4429ae28cedcd0c7ec5c060f633733996cea8
7
+ data.tar.gz: f00785bb224064d66c01486a4929fd74ba7102559f9280c3c95f619fb67034a41babcb7e2e93ffe74cb37a08848d7d19d9f0cc25a8aaab460ad5f667e695b926
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2025-06-19
11
+
12
+ ### Added
13
+ - Comprehensive test suite with RSpec covering all major components
14
+ - Test coverage for Configuration, FileAnalyzer, HealthCalculator, ProjectDetector, RailsAnalyzer, and ReportGenerator
15
+
16
+ ### Changed
17
+ - Enhanced Configuration class with improved validation and error handling
18
+ - Improved FileAnalyzer with better file processing capabilities
19
+ - Updated HealthCalculator with more robust scoring algorithms
20
+ - Enhanced ProjectDetector with better Rails project detection
21
+ - Improved RailsAnalyzer with more comprehensive Rails pattern analysis
22
+ - Enhanced ReportGenerator with better formatting and output options
23
+
24
+ ### Fixed
25
+ - Various bug fixes and improvements based on user feedback
26
+
10
27
  ## [0.1.0] - 2025-06-17
11
28
 
12
29
  ### Added
@@ -26,5 +43,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
26
43
  - **Reporting**: Detailed console output with health categories and JSON export
27
44
  - **CLI**: `rails-health` command with options for format, output file, and custom configuration
28
45
 
29
- [Unreleased]: https://github.com/yourusername/rails_code_health/compare/v0.1.0...HEAD
30
- [0.1.0]: https://github.com/yourusername/rails_code_health/releases/tag/v0.1.0
46
+ [Unreleased]: https://github.com/gkosmo/rails_code_health/compare/v0.2.0...HEAD
47
+ [0.2.0]: https://github.com/gkosmo/rails_code_health/compare/v0.1.0...v0.2.0
48
+ [0.1.0]: https://github.com/gkosmo/rails_code_health/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # Rails Code Health
2
2
 
3
- A Ruby gem that evaluates the code health of Ruby on Rails applications, inspired by CodeScene's research on technical debt and maintainability.
3
+ A Ruby gem that evaluates the code health of Ruby on Rails applications, [inspired by CodeScene's research on technical debt and maintainability](https://arxiv.org/pdf/2203.04374
4
+ ).
4
5
 
5
6
  ## Overview
6
7
 
@@ -64,6 +64,10 @@ module RailsCodeHealth
64
64
  'view_length' => { 'green' => 30, 'yellow' => 50, 'red' => 100 },
65
65
  'migration_complexity' => { 'green' => 10, 'yellow' => 20, 'red' => 40 }
66
66
  },
67
+ 'service_thresholds' => {
68
+ 'dependency_count' => { 'green' => 3, 'yellow' => 5, 'red' => 8 },
69
+ 'complexity_score' => { 'green' => 10, 'yellow' => 15, 'red' => 25 }
70
+ },
67
71
  'file_type_multipliers' => {
68
72
  'controllers' => 1.2,
69
73
  'models' => 1.0,
@@ -71,7 +75,16 @@ module RailsCodeHealth
71
75
  'helpers' => 0.9,
72
76
  'lib' => 1.1,
73
77
  'specs' => 0.7,
74
- 'migrations' => 0.6
78
+ 'migrations' => 0.6,
79
+ 'service' => 1.3,
80
+ 'interactor' => 1.2,
81
+ 'serializer' => 0.9,
82
+ 'form' => 1.1,
83
+ 'decorator' => 0.9,
84
+ 'presenter' => 0.9,
85
+ 'policy' => 1.2,
86
+ 'job' => 1.0,
87
+ 'worker' => 1.0
75
88
  },
76
89
  'scoring_weights' => {
77
90
  'method_length' => 0.15,
@@ -80,7 +80,10 @@ module RailsCodeHealth
80
80
  view_patterns = [
81
81
  @project_path + 'app/views/**/*.erb',
82
82
  @project_path + 'app/views/**/*.haml',
83
- @project_path + 'app/views/**/*.slim'
83
+ @project_path + 'app/views/**/*.slim',
84
+ @project_path + 'app/**/views/**/*.erb',
85
+ @project_path + 'app/**/views/**/*.haml',
86
+ @project_path + 'app/**/views/**/*.slim'
84
87
  ]
85
88
 
86
89
  files = []
@@ -107,6 +107,12 @@ module RailsCodeHealth
107
107
  penalties.concat(calculate_helper_penalties(rails_data))
108
108
  when :migration
109
109
  penalties.concat(calculate_migration_penalties(rails_data))
110
+ when :service
111
+ penalties.concat(calculate_service_penalties(rails_data))
112
+ when :interactor
113
+ penalties.concat(calculate_interactor_penalties(rails_data))
114
+ when :serializer
115
+ penalties.concat(calculate_serializer_penalties(rails_data))
110
116
  end
111
117
 
112
118
  penalties
@@ -132,6 +138,11 @@ module RailsCodeHealth
132
138
  penalties << 1.5 * @weights['rails_conventions']
133
139
  end
134
140
 
141
+ # Business logic in controller
142
+ if controller_data[:has_business_logic]
143
+ penalties << 2.0 * @weights['rails_conventions']
144
+ end
145
+
135
146
  penalties
136
147
  end
137
148
 
@@ -212,6 +223,102 @@ module RailsCodeHealth
212
223
  penalties
213
224
  end
214
225
 
226
+ def calculate_service_penalties(service_data)
227
+ penalties = []
228
+
229
+ # Missing call method - critical for services
230
+ unless service_data[:has_call_method]
231
+ penalties << 3.0 * @weights['rails_conventions']
232
+ end
233
+
234
+ # Too many dependencies
235
+ dependency_count = service_data[:dependencies]&.count || 0
236
+ service_thresholds = @thresholds['service_thresholds']['dependency_count']
237
+ if dependency_count > service_thresholds['yellow']
238
+ severity = dependency_count > service_thresholds['red'] ? 2.0 : 1.0
239
+ penalties << severity * @weights['rails_conventions']
240
+ end
241
+
242
+ # High complexity
243
+ complexity = service_data[:complexity_score] || 0
244
+ complexity_thresholds = @thresholds['service_thresholds']['complexity_score']
245
+ if complexity > complexity_thresholds['yellow']
246
+ severity = complexity > complexity_thresholds['red'] ? 2.0 : 1.0
247
+ penalties << severity * @weights['rails_conventions']
248
+ end
249
+
250
+ # Missing error handling
251
+ error_handling = service_data[:error_handling] || {}
252
+ unless error_handling[:has_error_handling]
253
+ penalties << 1.0 * @weights['rails_conventions']
254
+ end
255
+
256
+ penalties
257
+ end
258
+
259
+ def calculate_interactor_penalties(interactor_data)
260
+ penalties = []
261
+
262
+ # Missing call method - critical for interactors
263
+ unless interactor_data[:has_call_method]
264
+ penalties << 3.0 * @weights['rails_conventions']
265
+ end
266
+
267
+ # Complex organizers
268
+ if interactor_data[:is_organizer]
269
+ complexity = interactor_data[:complexity_score] || 0
270
+ if complexity > 20 # Organizers should be simple orchestrators
271
+ penalties << 2.0 * @weights['rails_conventions']
272
+ end
273
+ end
274
+
275
+ # Missing failure handling
276
+ fail_usage = interactor_data[:fail_usage] || {}
277
+ if (fail_usage[:context_fail] || 0) == 0 && (fail_usage[:fail_bang] || 0) == 0
278
+ penalties << 1.5 * @weights['rails_conventions']
279
+ end
280
+
281
+ # High complexity for regular interactors
282
+ unless interactor_data[:is_organizer]
283
+ complexity = interactor_data[:complexity_score] || 0
284
+ if complexity > 15
285
+ penalties << 1.5 * @weights['rails_conventions']
286
+ end
287
+ end
288
+
289
+ penalties
290
+ end
291
+
292
+ def calculate_serializer_penalties(serializer_data)
293
+ penalties = []
294
+
295
+ # Too many attributes/associations (fat serializer)
296
+ attribute_count = serializer_data[:attribute_count] || 0
297
+ association_count = serializer_data[:association_count] || 0
298
+ total_fields = attribute_count + association_count
299
+
300
+ if total_fields > 20
301
+ penalties << 2.0 * @weights['rails_conventions']
302
+ elsif total_fields > 15
303
+ penalties << 1.0 * @weights['rails_conventions']
304
+ end
305
+
306
+ # Too many custom methods (complex logic)
307
+ custom_method_count = serializer_data[:custom_method_count] || 0
308
+ if custom_method_count > 10
309
+ penalties << 1.5 * @weights['rails_conventions']
310
+ elsif custom_method_count > 5
311
+ penalties << 0.5 * @weights['rails_conventions']
312
+ end
313
+
314
+ # Empty serializer (no value)
315
+ if total_fields == 0 && custom_method_count == 0
316
+ penalties << 1.0 * @weights['rails_conventions']
317
+ end
318
+
319
+ penalties
320
+ end
321
+
215
322
  def calculate_code_smell_penalties(file_result)
216
323
  penalties = []
217
324
 
@@ -360,6 +467,24 @@ module RailsCodeHealth
360
467
  recommendations << "Reduce model callbacks (#{smell[:count]} found) - consider service objects"
361
468
  when :data_changes_in_migration
362
469
  recommendations << "Avoid data changes in migrations - use rake tasks instead"
470
+ when :business_logic_in_controller
471
+ recommendations << "Extract business logic from controller to service objects"
472
+ when :missing_call_method
473
+ recommendations << "Add a call method to follow service/interactor conventions"
474
+ when :fat_service
475
+ recommendations << "Break down service into smaller, focused services (complexity: #{smell[:complexity]})"
476
+ when :missing_error_handling
477
+ recommendations << "Add proper error handling with rescue blocks"
478
+ when :complex_organizer
479
+ recommendations << "Simplify organizer - it should only orchestrate other interactors (complexity: #{smell[:complexity]})"
480
+ when :missing_failure_handling
481
+ recommendations << "Add failure handling using context.fail or fail! methods"
482
+ when :fat_serializer
483
+ recommendations << "Split serializer - it has #{smell[:field_count]} fields"
484
+ when :complex_serializer
485
+ recommendations << "Move complex logic out of serializer (#{smell[:method_count]} custom methods)"
486
+ when :empty_serializer
487
+ recommendations << "Add attributes or associations to provide value"
363
488
  end
364
489
  end
365
490
  end
@@ -24,7 +24,7 @@ module RailsCodeHealth
24
24
  relative_path = file_path.relative_path_from(project_root).to_s
25
25
 
26
26
  case relative_path
27
- when %r{^app/controllers/.*_controller\.rb$}
27
+ when %r{^app/.*/controllers/.*_controller\.rb$}, %r{^app/controllers/.*_controller\.rb$}
28
28
  :controller
29
29
  when %r{^app/models/.*\.rb$}
30
30
  :model
@@ -32,6 +32,28 @@ module RailsCodeHealth
32
32
  :view
33
33
  when %r{^app/helpers/.*_helper\.rb$}
34
34
  :helper
35
+ when %r{^app/.*/services/.*\.rb$}, %r{^app/services/.*\.rb$}
36
+ :service
37
+ when %r{^app/.*/interactors/.*\.rb$}, %r{^app/interactors/.*\.rb$}
38
+ :interactor
39
+ when %r{^app/.*/serializers/.*\.rb$}, %r{^app/serializers/.*\.rb$}
40
+ :serializer
41
+ when %r{^app/.*/forms/.*\.rb$}, %r{^app/forms/.*\.rb$}
42
+ :form
43
+ when %r{^app/.*/decorators/.*\.rb$}, %r{^app/decorators/.*\.rb$}
44
+ :decorator
45
+ when %r{^app/.*/presenters/.*\.rb$}, %r{^app/presenters/.*\.rb$}
46
+ :presenter
47
+ when %r{^app/.*/policies/.*\.rb$}, %r{^app/policies/.*\.rb$}
48
+ :policy
49
+ when %r{^app/.*/jobs/.*\.rb$}, %r{^app/jobs/.*\.rb$}
50
+ :job
51
+ when %r{^app/.*/workers/.*\.rb$}, %r{^app/workers/.*\.rb$}
52
+ :worker
53
+ when %r{^app/.*/mailers/.*\.rb$}, %r{^app/mailers/.*\.rb$}
54
+ :mailer
55
+ when %r{^app/.*/channels/.*\.rb$}, %r{^app/channels/.*\.rb$}
56
+ :channel
35
57
  when %r{^lib/.*\.rb$}
36
58
  :lib
37
59
  when %r{^db/migrate/.*\.rb$}
@@ -45,8 +67,61 @@ module RailsCodeHealth
45
67
  end
46
68
  end
47
69
 
70
+ def detect_detailed_file_type(file_path, project_root)
71
+ relative_path = file_path.relative_path_from(project_root).to_s
72
+ file_type = detect_file_type(file_path, project_root)
73
+
74
+ context = {
75
+ organization: detect_organization_pattern(relative_path),
76
+ domain: extract_domain(relative_path),
77
+ area: extract_area(relative_path),
78
+ api_version: extract_api_version(relative_path)
79
+ }.compact
80
+
81
+ { type: file_type, context: context }
82
+ end
83
+
48
84
  private
49
85
 
86
+ def detect_organization_pattern(relative_path)
87
+ case relative_path
88
+ when %r{^app/topics/}
89
+ :topic_based
90
+ when %r{^app/domains/}
91
+ :domain_based
92
+ when %r{^app/modules/}
93
+ :module_based
94
+ else
95
+ :traditional
96
+ end
97
+ end
98
+
99
+ def extract_domain(relative_path)
100
+ case relative_path
101
+ when %r{^app/topics/([^/]+)/}
102
+ $1
103
+ when %r{^app/domains/([^/]+)/}
104
+ $1
105
+ when %r{^app/modules/([^/]+)/}
106
+ $1
107
+ else
108
+ nil
109
+ end
110
+ end
111
+
112
+ def extract_area(relative_path)
113
+ areas = %w[admin backoffice api]
114
+ areas.each do |area|
115
+ return area if relative_path.include?("/#{area}/")
116
+ end
117
+ nil
118
+ end
119
+
120
+ def extract_api_version(relative_path)
121
+ match = relative_path.match(%r{/v(\d+)/})
122
+ match ? "v#{match[1]}" : nil
123
+ end
124
+
50
125
  def has_rails_files?(path)
51
126
  RAILS_INDICATORS.any? { |file| (path + file).exist? }
52
127
  end
@@ -21,6 +21,12 @@ module RailsCodeHealth
21
21
  analyze_helper
22
22
  when :migration
23
23
  analyze_migration
24
+ when :service
25
+ analyze_service
26
+ when :interactor
27
+ analyze_interactor
28
+ when :serializer
29
+ analyze_serializer
24
30
  else
25
31
  {}
26
32
  end
@@ -37,6 +43,7 @@ module RailsCodeHealth
37
43
  has_before_actions: has_before_actions?,
38
44
  uses_strong_parameters: uses_strong_parameters?,
39
45
  has_direct_model_access: has_direct_model_access?,
46
+ has_business_logic: has_business_logic?,
40
47
  response_formats: detect_response_formats,
41
48
  rails_smells: detect_controller_smells
42
49
  }
@@ -137,6 +144,18 @@ module RailsCodeHealth
137
144
  formats
138
145
  end
139
146
 
147
+ def has_business_logic?
148
+ business_logic_patterns = [
149
+ /if.*&&.*/, # Complex conditionals
150
+ /\.each\s*do/, # Iteration
151
+ /\b(calculate|compute|process)\b/, # Business operations
152
+ /\.(sum|count|average)\b/, # Aggregations
153
+ /transaction\s*do/ # Database transactions
154
+ ]
155
+
156
+ business_logic_patterns.any? { |pattern| @source.match?(pattern) }
157
+ end
158
+
140
159
  def detect_controller_smells
141
160
  smells = []
142
161
 
@@ -163,6 +182,13 @@ module RailsCodeHealth
163
182
  }
164
183
  end
165
184
 
185
+ if has_business_logic?
186
+ smells << {
187
+ type: :business_logic_in_controller,
188
+ severity: :high
189
+ }
190
+ end
191
+
166
192
  smells
167
193
  end
168
194
 
@@ -372,6 +398,301 @@ module RailsCodeHealth
372
398
  smells
373
399
  end
374
400
 
401
+ # Service analysis methods
402
+ def analyze_service
403
+ return {} unless @ast
404
+
405
+ {
406
+ rails_type: :service,
407
+ has_call_method: has_call_method?,
408
+ dependencies: detect_service_dependencies,
409
+ error_handling: detect_error_handling,
410
+ complexity_score: calculate_service_complexity,
411
+ rails_smells: detect_service_smells
412
+ }
413
+ end
414
+
415
+ def has_call_method?
416
+ return false unless @ast
417
+
418
+ has_instance_call = false
419
+ has_class_call = false
420
+
421
+ find_nodes(@ast, :def) do |node|
422
+ method_name = node.children[0].to_s
423
+ has_instance_call = true if method_name == 'call'
424
+ end
425
+
426
+ find_nodes(@ast, :defs) do |node|
427
+ method_name = node.children[1].to_s
428
+ has_class_call = true if method_name == 'call'
429
+ end
430
+
431
+ has_instance_call || has_class_call
432
+ end
433
+
434
+ def detect_service_dependencies
435
+ dependencies = []
436
+
437
+ # ActiveRecord usage
438
+ if @source.match?(/\w+\.find\(/) || @source.match?(/\w+\.where\(/) || @source.match?(/\w+\.create\(/)
439
+ dependencies << :active_record
440
+ end
441
+
442
+ # External APIs
443
+ if @source.include?('Net::HTTP') || @source.include?('HTTParty') || @source.include?('Faraday')
444
+ dependencies << :external_api
445
+ end
446
+
447
+ # File system
448
+ if @source.include?('File.') || @source.include?('Dir.') || @source.include?('FileUtils')
449
+ dependencies << :file_system
450
+ end
451
+
452
+ # Email
453
+ if @source.include?('Mailer') || @source.include?('deliver') || @source.include?('ActionMailer')
454
+ dependencies << :email
455
+ end
456
+
457
+ # Cache
458
+ if @source.include?('Rails.cache') || @source.include?('cache_store') || @source.include?('Redis')
459
+ dependencies << :cache
460
+ end
461
+
462
+ dependencies.uniq
463
+ end
464
+
465
+ def detect_error_handling
466
+ error_handling = {
467
+ rescue_blocks: @source.scan(/rescue/).count,
468
+ raise_statements: @source.scan(/raise/).count
469
+ }
470
+
471
+ error_handling[:has_error_handling] = error_handling[:rescue_blocks] > 0 || error_handling[:raise_statements] > 0
472
+ error_handling
473
+ end
474
+
475
+ def calculate_service_complexity
476
+ complexity = 0
477
+
478
+ # Base complexity from dependencies
479
+ complexity += detect_service_dependencies.count * 2
480
+
481
+ # Conditional complexity
482
+ complexity += @source.scan(/\bif\b/).count
483
+ complexity += @source.scan(/\bunless\b/).count
484
+ complexity += @source.scan(/\bcase\b/).count
485
+
486
+ # Error handling complexity
487
+ error_handling = detect_error_handling
488
+ complexity += error_handling[:rescue_blocks] * 2
489
+ complexity += error_handling[:raise_statements]
490
+
491
+ complexity
492
+ end
493
+
494
+ def detect_service_smells
495
+ smells = []
496
+
497
+ unless has_call_method?
498
+ smells << {
499
+ type: :missing_call_method,
500
+ severity: :high
501
+ }
502
+ end
503
+
504
+ complexity = calculate_service_complexity
505
+ if complexity > 15
506
+ smells << {
507
+ type: :fat_service,
508
+ complexity: complexity,
509
+ severity: :high
510
+ }
511
+ end
512
+
513
+ error_handling = detect_error_handling
514
+ unless error_handling[:has_error_handling]
515
+ smells << {
516
+ type: :missing_error_handling,
517
+ severity: :medium
518
+ }
519
+ end
520
+
521
+ smells
522
+ end
523
+
524
+ # Interactor analysis methods
525
+ def analyze_interactor
526
+ return {} unless @ast
527
+
528
+ {
529
+ rails_type: :interactor,
530
+ has_call_method: has_call_method?,
531
+ context_usage: detect_context_usage,
532
+ fail_usage: detect_fail_usage,
533
+ is_organizer: is_organizer?,
534
+ complexity_score: calculate_interactor_complexity,
535
+ rails_smells: detect_interactor_smells
536
+ }
537
+ end
538
+
539
+ def detect_context_usage
540
+ {
541
+ context_references: @source.scan(/(?<!@)\bcontext\./).count,
542
+ instance_context_references: @source.scan(/@context/).count
543
+ }
544
+ end
545
+
546
+ def detect_fail_usage
547
+ {
548
+ context_fail: @source.scan(/context\.fail/).count,
549
+ fail_bang: @source.scan(/fail!/).count
550
+ }
551
+ end
552
+
553
+ def is_organizer?
554
+ @source.include?('organize') || @source.include?('Interactor::Organizer')
555
+ end
556
+
557
+ def calculate_interactor_complexity
558
+ complexity = 0
559
+
560
+ # Base complexity for organizers
561
+ complexity += 5 if is_organizer?
562
+
563
+ # Context usage complexity
564
+ context_usage = detect_context_usage
565
+ complexity += context_usage[:context_references]
566
+ complexity += context_usage[:instance_context_references]
567
+
568
+ # Conditional complexity
569
+ complexity += @source.scan(/\bif\b/).count
570
+ complexity += @source.scan(/\bunless\b/).count
571
+
572
+ # Fail usage adds complexity
573
+ fail_usage = detect_fail_usage
574
+ complexity += fail_usage[:context_fail] * 2
575
+ complexity += fail_usage[:fail_bang] * 2
576
+
577
+ complexity
578
+ end
579
+
580
+ def detect_interactor_smells
581
+ smells = []
582
+
583
+ unless has_call_method?
584
+ smells << {
585
+ type: :missing_call_method,
586
+ severity: :high
587
+ }
588
+ end
589
+
590
+ if is_organizer?
591
+ complexity = calculate_interactor_complexity
592
+ if complexity > 20
593
+ smells << {
594
+ type: :complex_organizer,
595
+ complexity: complexity,
596
+ severity: :high
597
+ }
598
+ end
599
+ end
600
+
601
+ fail_usage = detect_fail_usage
602
+ if fail_usage[:context_fail] == 0 && fail_usage[:fail_bang] == 0
603
+ smells << {
604
+ type: :missing_failure_handling,
605
+ severity: :medium
606
+ }
607
+ end
608
+
609
+ smells
610
+ end
611
+
612
+ # Serializer analysis methods
613
+ def analyze_serializer
614
+ return {} unless @ast
615
+
616
+ {
617
+ rails_type: :serializer,
618
+ attribute_count: count_serializer_attributes,
619
+ association_count: count_serializer_associations,
620
+ custom_method_count: count_custom_serializer_methods,
621
+ has_conditional_attributes: has_conditional_attributes?,
622
+ rails_smells: detect_serializer_smells
623
+ }
624
+ end
625
+
626
+ def count_serializer_attributes
627
+ # Count attributes declarations (lines starting with attributes/attribute)
628
+ @source.scan(/^\s*attributes?\s+/).count + @source.scan(/^\s*attribute\s+/).count
629
+ end
630
+
631
+ def count_serializer_associations
632
+ associations = 0
633
+ association_methods = %w[has_one has_many belongs_to]
634
+
635
+ association_methods.each do |method|
636
+ associations += @source.scan(/^\s*#{method}\s+/).count
637
+ end
638
+
639
+ associations
640
+ end
641
+
642
+ def count_custom_serializer_methods
643
+ return 0 unless @ast
644
+
645
+ method_count = 0
646
+ standard_methods = %w[initialize attributes serialize serializable_hash]
647
+
648
+ find_nodes(@ast, :def) do |node|
649
+ method_name = node.children[0].to_s
650
+ unless standard_methods.include?(method_name) || method_name.start_with?('_')
651
+ method_count += 1
652
+ end
653
+ end
654
+
655
+ method_count
656
+ end
657
+
658
+ def has_conditional_attributes?
659
+ @source.include?('if:') || @source.include?('unless:') || @source.include?('condition:')
660
+ end
661
+
662
+ def detect_serializer_smells
663
+ smells = []
664
+
665
+ attribute_count = count_serializer_attributes
666
+ association_count = count_serializer_associations
667
+ total_fields = attribute_count + association_count
668
+
669
+ if total_fields > 20
670
+ smells << {
671
+ type: :fat_serializer,
672
+ field_count: total_fields,
673
+ severity: :high
674
+ }
675
+ end
676
+
677
+ custom_method_count = count_custom_serializer_methods
678
+ if custom_method_count > 10
679
+ smells << {
680
+ type: :complex_serializer,
681
+ method_count: custom_method_count,
682
+ severity: :medium
683
+ }
684
+ end
685
+
686
+ if attribute_count == 0 && association_count == 0
687
+ smells << {
688
+ type: :empty_serializer,
689
+ severity: :low
690
+ }
691
+ end
692
+
693
+ smells
694
+ end
695
+
375
696
  # Helper methods
376
697
  def find_nodes(node, type, &block)
377
698
  return unless node.is_a?(Parser::AST::Node)
@@ -58,18 +58,110 @@ module RailsCodeHealth
58
58
 
59
59
  file_types = @results.group_by { |r| r[:file_type] }
60
60
 
61
- file_types.each do |type, files|
61
+ # Define the order for file types
62
+ type_order = [
63
+ :controller, :model, :view, :helper, :migration,
64
+ :service, :interactor, :serializer, :form, :decorator,
65
+ :presenter, :policy, :job, :worker, :mailer, :channel,
66
+ :lib, :config, :test, :ruby
67
+ ]
68
+
69
+ # Sort file types by the defined order, with unknown types at the end
70
+ sorted_types = file_types.keys.sort_by do |type|
71
+ index = type_order.index(type)
72
+ index || type_order.length
73
+ end
74
+
75
+ sorted_types.each do |type, _|
76
+ files = file_types[type]
62
77
  next if files.empty?
63
78
 
64
79
  avg_score = (files.sum { |f| f[:health_score] || 0 } / files.count.to_f).round(1)
65
80
  healthy_count = files.count { |f| f[:health_category] == :healthy }
66
81
 
67
- breakdown << " #{type.to_s.capitalize}: #{files.count} files, avg score: #{avg_score}, #{healthy_count} healthy"
82
+ # Get file type emoji
83
+ emoji = get_file_type_emoji(type)
84
+
85
+ # Show context breakdown for new file types
86
+ context_info = ""
87
+ if [:service, :interactor, :serializer, :form, :decorator, :presenter, :policy, :job, :worker].include?(type)
88
+ context_info = generate_context_breakdown(files)
89
+ end
90
+
91
+ breakdown << " #{emoji} #{type.to_s.capitalize}: #{files.count} files, avg score: #{avg_score}, #{healthy_count} healthy"
92
+ breakdown << context_info if !context_info.empty?
68
93
  end
69
94
 
70
95
  breakdown.join("\n")
71
96
  end
72
97
 
98
+ def get_file_type_emoji(type)
99
+ emoji_map = {
100
+ controller: "🎮",
101
+ model: "📊",
102
+ view: "🖼️",
103
+ helper: "🔧",
104
+ migration: "📈",
105
+ service: "⚙️",
106
+ interactor: "🔄",
107
+ serializer: "📦",
108
+ form: "📝",
109
+ decorator: "🎨",
110
+ presenter: "🎪",
111
+ policy: "🛡️",
112
+ job: "⚡",
113
+ worker: "👷",
114
+ mailer: "📧",
115
+ channel: "📡",
116
+ lib: "📚",
117
+ config: "⚙️",
118
+ test: "🧪",
119
+ ruby: "💎"
120
+ }
121
+ emoji_map[type] || "📄"
122
+ end
123
+
124
+ def generate_context_breakdown(files)
125
+ # Check if any files have context information
126
+ files_with_context = files.select { |f| f[:context] && !f[:context].empty? }
127
+ return "" if files_with_context.empty?
128
+
129
+ breakdown_lines = []
130
+
131
+ # Organization patterns
132
+ organizations = files_with_context.group_by { |f| f[:context][:organization] }
133
+ if organizations.keys.any? { |org| org != :traditional }
134
+ org_breakdown = organizations.map do |org, org_files|
135
+ next if org == :traditional
136
+ "#{org.to_s.gsub('_', ' ').capitalize}: #{org_files.count}"
137
+ end.compact
138
+ breakdown_lines << " 📁 Organization: #{org_breakdown.join(', ')}" if org_breakdown.any?
139
+ end
140
+
141
+ # Domains
142
+ domains = files_with_context.group_by { |f| f[:context][:domain] }.reject { |domain, _| domain.nil? }
143
+ if domains.any?
144
+ domain_breakdown = domains.map { |domain, domain_files| "#{domain}: #{domain_files.count}" }
145
+ breakdown_lines << " 🏢 Domains: #{domain_breakdown.join(', ')}"
146
+ end
147
+
148
+ # Areas
149
+ areas = files_with_context.group_by { |f| f[:context][:area] }.reject { |area, _| area.nil? }
150
+ if areas.any?
151
+ area_breakdown = areas.map { |area, area_files| "#{area}: #{area_files.count}" }
152
+ breakdown_lines << " 🏠 Areas: #{area_breakdown.join(', ')}"
153
+ end
154
+
155
+ # API versions
156
+ api_versions = files_with_context.group_by { |f| f[:context][:api_version] }.reject { |version, _| version.nil? }
157
+ if api_versions.any?
158
+ version_breakdown = api_versions.map { |version, version_files| "#{version}: #{version_files.count}" }
159
+ breakdown_lines << " 🔢 API Versions: #{version_breakdown.join(', ')}"
160
+ end
161
+
162
+ breakdown_lines.join("\n")
163
+ end
164
+
73
165
  def generate_detailed_report
74
166
  detailed = []
75
167
  detailed << "📋 Detailed File Analysis"
@@ -174,7 +266,21 @@ module RailsCodeHealth
174
266
  end
175
267
 
176
268
  summary << "#{rank}. #{prefix} #{health_emoji} #{result[:relative_path]}"
177
- summary << " Score: #{result[:health_score]}/10.0 | Type: #{result[:file_type]} | Size: #{format_file_size(result[:file_size])}"
269
+
270
+ # Build context string
271
+ context_parts = ["Type: #{result[:file_type]}", "Size: #{format_file_size(result[:file_size])}"]
272
+
273
+ if result[:context] && !result[:context].empty?
274
+ context_info = []
275
+ context_info << "#{result[:context][:domain]}" if result[:context][:domain]
276
+ context_info << "#{result[:context][:area]}" if result[:context][:area]
277
+ context_info << "#{result[:context][:api_version]}" if result[:context][:api_version]
278
+ context_info << "#{result[:context][:organization].to_s.gsub('_', ' ')}" if result[:context][:organization] && result[:context][:organization] != :traditional
279
+
280
+ context_parts << "Context: #{context_info.join(', ')}" if context_info.any?
281
+ end
282
+
283
+ summary << " Score: #{result[:health_score]}/10.0 | #{context_parts.join(' | ')}"
178
284
 
179
285
  # Add key metrics if available
180
286
  if result[:ruby_analysis]
@@ -200,11 +306,24 @@ module RailsCodeHealth
200
306
  case result[:rails_analysis][:rails_type]
201
307
  when :controller
202
308
  rails_info << "#{result[:rails_analysis][:action_count]} actions"
309
+ rails_info << "business logic" if result[:rails_analysis][:has_business_logic]
203
310
  when :model
204
311
  rails_info << "#{result[:rails_analysis][:association_count]} associations" if result[:rails_analysis][:association_count]
205
312
  rails_info << "#{result[:rails_analysis][:validation_count]} validations" if result[:rails_analysis][:validation_count]
206
313
  when :view
207
314
  rails_info << "#{result[:rails_analysis][:logic_lines]} logic lines" if result[:rails_analysis][:logic_lines]
315
+ when :service
316
+ rails_info << "call method" if result[:rails_analysis][:has_call_method]
317
+ rails_info << "deps: #{result[:rails_analysis][:dependencies].join(', ')}" if result[:rails_analysis][:dependencies]&.any?
318
+ rails_info << "complexity: #{result[:rails_analysis][:complexity_score]}" if result[:rails_analysis][:complexity_score]
319
+ when :interactor
320
+ rails_info << "call method" if result[:rails_analysis][:has_call_method]
321
+ rails_info << "organizer" if result[:rails_analysis][:is_organizer]
322
+ rails_info << "complexity: #{result[:rails_analysis][:complexity_score]}" if result[:rails_analysis][:complexity_score]
323
+ when :serializer
324
+ rails_info << "#{result[:rails_analysis][:attribute_count]} attributes" if result[:rails_analysis][:attribute_count]
325
+ rails_info << "#{result[:rails_analysis][:association_count]} associations" if result[:rails_analysis][:association_count]
326
+ rails_info << "#{result[:rails_analysis][:custom_method_count]} custom methods" if result[:rails_analysis][:custom_method_count]
208
327
  end
209
328
 
210
329
  summary << " Rails: #{rails_info.join(', ')}" if rails_info.any?
@@ -296,6 +415,7 @@ module RailsCodeHealth
296
415
  when :controller
297
416
  metrics[:controller_actions] = rails_metrics[:action_count]
298
417
  metrics[:uses_strong_parameters] = rails_metrics[:uses_strong_parameters]
418
+ metrics[:has_business_logic] = rails_metrics[:has_business_logic]
299
419
  when :model
300
420
  metrics[:associations] = rails_metrics[:association_count]
301
421
  metrics[:validations] = rails_metrics[:validation_count]
@@ -303,6 +423,21 @@ module RailsCodeHealth
303
423
  when :view
304
424
  metrics[:view_logic_lines] = rails_metrics[:logic_lines]
305
425
  metrics[:has_inline_styles] = rails_metrics[:has_inline_styles]
426
+ when :service
427
+ metrics[:has_call_method] = rails_metrics[:has_call_method]
428
+ metrics[:dependencies] = rails_metrics[:dependencies]
429
+ metrics[:complexity_score] = rails_metrics[:complexity_score]
430
+ metrics[:error_handling] = rails_metrics[:error_handling]
431
+ when :interactor
432
+ metrics[:has_call_method] = rails_metrics[:has_call_method]
433
+ metrics[:is_organizer] = rails_metrics[:is_organizer]
434
+ metrics[:context_usage] = rails_metrics[:context_usage]
435
+ metrics[:complexity_score] = rails_metrics[:complexity_score]
436
+ when :serializer
437
+ metrics[:attribute_count] = rails_metrics[:attribute_count]
438
+ metrics[:association_count] = rails_metrics[:association_count]
439
+ metrics[:custom_method_count] = rails_metrics[:custom_method_count]
440
+ metrics[:has_conditional_attributes] = rails_metrics[:has_conditional_attributes]
306
441
  end
307
442
  end
308
443
 
@@ -1,3 +1,3 @@
1
1
  module RailsCodeHealth
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_code_health
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - George Kosmopoulos
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-06-17 00:00:00.000000000 Z
11
+ date: 2025-06-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: parser