rails_code_health 0.1.0 → 0.3.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: cde06b2eaad01a08ed9a618103f62aff853e4adcb4f3e6a2b93807e2e00d2dfa
4
+ data.tar.gz: 829b9eaa19feb6cbef043fa73822c48a204c25afc552ccf557193ff3498f6e09
5
5
  SHA512:
6
- metadata.gz: 416a30e4fd42da4f2f8fe100e349c611c009d5468710974c59d945036145ce1c0df543ca1a8f51f3bf53868cd6ce9472e4be3457dcdaca3d5de12258ef16ed77
7
- data.tar.gz: 44e261b45a963e81c5d3ace4be6b360ff8c052ba39dd4b0e3fa56789c145673e3b51d5359ebed08a2f7eb50214044031f0f6185760e3ac06072531be25659769
6
+ metadata.gz: 8e40c5161b69aaf9f98318f7204cf7c68b9a376464179cd3169aad3c60137c629feee9870f68ed6fe3578aa13d129aa426c12211f348d5d18b3bc06d74e5e6eb
7
+ data.tar.gz: 36c0b8ec173f03bf41008b14674a07bb5b5748bbd3c8e73bed0c0ac03cf19d9203578ca65bf4b650570a0626caa98a2847dd59ef59022b0afe9bbef0aefb59de
data/CHANGELOG.md CHANGED
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-05-12
11
+
12
+ ### Added
13
+ - `RailsCodeHealth::ASTHelpers` module: shared AST traversal with scoped variants and visibility-aware def collection.
14
+ - Fixture-based RSpec test suite under `spec/fixtures/code_samples/`.
15
+ - New configuration group `smell_thresholds` for previously hard-coded values.
16
+
17
+ ### Fixed
18
+ - `RubyAnalyzer#count_public_methods_in_class` now actually distinguishes public from private methods (including inline `private def foo`).
19
+ - `RubyAnalyzer#count_parameters` now counts only true parameter nodes.
20
+ - `RubyAnalyzer` nesting depth calculation no longer counts `:begin` and `:block` as nesting levels.
21
+ - `RailsAnalyzer#count_controller_actions` no longer inverts public/private; the seven canonical RESTful actions are correctly counted, and private helpers are excluded.
22
+ - `RailsAnalyzer#has_direct_model_access?` only fires when model calls appear inside public controller actions.
23
+ - `RailsAnalyzer#has_business_logic?` uses stricter signals (business-verb calls on model receivers, transactions, loops with conditionals) instead of matching any compound `if` or any `.each do`.
24
+ - Model `count_associations`, `count_validations`, `count_callbacks`, `count_scopes` count only top-level class body macros — no comments, strings, or nested-class matches.
25
+ - `has_fat_model_smell?` uses class-scoped code-line count and class-scoped method count.
26
+ - View `count_view_logic_lines` parses ERB fragments and no longer flags plain HTML that happens to contain `if`, `unless`, etc., in text.
27
+ - Migration `has_data_changes?` recognizes `find_each`, `update`, `update_columns`, `update_column`, `update!`, `update_all`, `delete_all`, `save`, `save!`, raw `connection.execute`, and `execute`.
28
+ - Service `detect_service_dependencies` uses word boundaries — `Profile.` no longer matches the `File.` check.
29
+ - God class, high complexity, parameter, and nesting smell thresholds are now read from configuration instead of inlined.
30
+ - Service `detect_service_smells` now flags `:fat_service` at complexity ≥ 13 (was > 15, which left some genuinely-fat services unflagged).
31
+ - Interactor `detect_fail_usage` no longer double-counts `context.fail!` as both `context_fail` and `fail_bang`.
32
+ - Serializer `count_serializer_attributes` / `count_serializer_associations` now count each symbol (`attributes :a, :b, :c` = 3) instead of just one per declaration line.
33
+ - `FileAnalyzer#find_view_files` dedupes overlapping glob matches so files under `app/views/` are no longer counted twice when matched by both `app/views/**/*.erb` and `app/**/views/**/*.erb`.
34
+ - `HealthCalculator` service/interactor/serializer penalties now use flat (un-weighted) multipliers for the most severe issues (missing call method, fat service, complex organizer, fat serializer) — the global `rails_conventions` weight (0.15) was too small to make these matter.
35
+
36
+ ### Changed (may affect scores)
37
+ - Score changes are expected on any project where the above false positives or false negatives applied. Re-baseline before comparing to v0.2.0 reports.
38
+ - Services missing a `call` method now score noticeably lower (penalty went from ~0.45 to 3.0); same for interactors. Fat serializers and complex organizers are also penalized more strongly.
39
+
40
+ ## [0.2.0] - 2025-06-19
41
+
42
+ ### Added
43
+ - Comprehensive test suite with RSpec covering all major components
44
+ - Test coverage for Configuration, FileAnalyzer, HealthCalculator, ProjectDetector, RailsAnalyzer, and ReportGenerator
45
+
46
+ ### Changed
47
+ - Enhanced Configuration class with improved validation and error handling
48
+ - Improved FileAnalyzer with better file processing capabilities
49
+ - Updated HealthCalculator with more robust scoring algorithms
50
+ - Enhanced ProjectDetector with better Rails project detection
51
+ - Improved RailsAnalyzer with more comprehensive Rails pattern analysis
52
+ - Enhanced ReportGenerator with better formatting and output options
53
+
54
+ ### Fixed
55
+ - Various bug fixes and improvements based on user feedback
56
+
10
57
  ## [0.1.0] - 2025-06-17
11
58
 
12
59
  ### Added
@@ -26,5 +73,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
26
73
  - **Reporting**: Detailed console output with health categories and JSON export
27
74
  - **CLI**: `rails-health` command with options for format, output file, and custom configuration
28
75
 
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
76
+ [Unreleased]: https://github.com/gkosmo/rails_code_health/compare/v0.3.0...HEAD
77
+ [0.3.0]: https://github.com/gkosmo/rails_code_health/compare/v0.2.0...v0.3.0
78
+ [0.2.0]: https://github.com/gkosmo/rails_code_health/compare/v0.1.0...v0.2.0
79
+ [0.1.0]: https://github.com/gkosmo/rails_code_health/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,6 +1,9 @@
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
+ [![CI](https://github.com/gkosmo/rails_code_health/actions/workflows/ci.yml/badge.svg)](https://github.com/gkosmo/rails_code_health/actions/workflows/ci.yml)
4
+
5
+ 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
6
+ ).
4
7
 
5
8
  ## Overview
6
9
 
@@ -126,6 +129,8 @@ Health scores range from 1.0 (critical) to 10.0 (excellent) based on:
126
129
  - **🔴 Alert (1.0-3.9)**: Significant problems requiring attention
127
130
  - **⚫ Critical (<1.0)**: Severe issues, immediate action needed
128
131
 
132
+ > **Note on v0.3.0 scores:** v0.3.0 fixes several false positives and false negatives in the Rails-specific checks. Scores on the same codebase will move compared to v0.2.0. See the CHANGELOG for the list of changed checks.
133
+
129
134
  ## Configuration
130
135
 
131
136
  Create a custom `thresholds.json` file:
@@ -0,0 +1,126 @@
1
+ module RailsCodeHealth
2
+ module ASTHelpers
3
+ SCOPE_BOUNDARY_TYPES = %i[class module def defs sclass].freeze
4
+ VISIBILITY_MODIFIERS = %i[public private protected].freeze
5
+
6
+ # Yields every descendant (and the node itself) of the given type.
7
+ # Descends into all children.
8
+ def find_nodes(node, type, &block)
9
+ return unless node.is_a?(Parser::AST::Node)
10
+
11
+ yield(node) if node.type == type
12
+ node.children.each { |child| find_nodes(child, type, &block) }
13
+ end
14
+
15
+ # Returns a hash { public: [def_nodes], private: [def_nodes], protected: [def_nodes] }
16
+ # for the given class node. Handles bare modifier blocks and inline `private def foo`.
17
+ # Defs inside `class << self` are excluded (the :sclass node is not descended into).
18
+ #
19
+ # NOTE: `private :symbol_name` / `private :a, :b` forms are NOT handled —
20
+ # methods so marked stay classified as :public. Only bare modifier blocks
21
+ # (`private` on its own line) and inline `private def foo` are recognized.
22
+ def defs_by_visibility(class_node)
23
+ result = { public: [], private: [], protected: [] }
24
+ return result unless class_node.is_a?(Parser::AST::Node)
25
+ return result unless %i[class module].include?(class_node.type)
26
+
27
+ current = :public
28
+ body = class_body_children(class_node)
29
+
30
+ body.each do |child|
31
+ next unless child.is_a?(Parser::AST::Node)
32
+
33
+ if child.type == :send && child.children[0].nil? &&
34
+ VISIBILITY_MODIFIERS.include?(child.children[1])
35
+ modifier = child.children[1]
36
+ if child.children.length == 2
37
+ # bare modifier — changes default for subsequent defs
38
+ current = modifier
39
+ else
40
+ # inline form: `private def foo` or `private :foo`
41
+ inline_target = child.children[2]
42
+ if inline_target.is_a?(Parser::AST::Node) && inline_target.type == :def
43
+ result[modifier] << inline_target
44
+ end
45
+ end
46
+ elsif child.type == :def
47
+ result[current] << child
48
+ end
49
+ end
50
+
51
+ result
52
+ end
53
+
54
+ # Returns direct `:send` nodes in the class body matching the given method name.
55
+ # Does not descend into nested classes, modules, or method bodies.
56
+ #
57
+ # NOTE: calls wrapped in a block (e.g., `included do ... end` in concerns)
58
+ # are NOT matched — the wrapping :block node is not a :send. Use
59
+ # class_body_children directly and inspect :block nodes if you need to find
60
+ # macro calls inside such wrappers.
61
+ def class_body_sends(class_node, method_name)
62
+ matches = []
63
+ return matches unless class_node.is_a?(Parser::AST::Node)
64
+ return matches unless %i[class module].include?(class_node.type)
65
+
66
+ class_body_children(class_node).each do |child|
67
+ next unless child.is_a?(Parser::AST::Node)
68
+ next unless child.type == :send
69
+ # Receiver-less send only (e.g., `has_many :foo`, not `MyMod.has_many :foo`)
70
+ next unless child.children[0].nil?
71
+ matches << child if child.children[1] == method_name
72
+ end
73
+
74
+ matches
75
+ end
76
+
77
+ # Yields each Ruby code fragment inside <% %> / <%= %> / <%- %> tags.
78
+ # Multi-line tags are handled. ERB comment tags (<%# ... %>) are skipped.
79
+ # Returns an Enumerator if no block given.
80
+ def erb_ruby_fragments(source)
81
+ return enum_for(:erb_ruby_fragments, source) unless block_given?
82
+
83
+ source.scan(/<%(?!#)=?-?(.*?)-?%>/m) do |match|
84
+ yield match[0]
85
+ end
86
+ end
87
+
88
+ # Like find_nodes, but does not descend into nested class/module/def/defs/sclass
89
+ # nodes. The starting node itself is inspected; all children are inspected, but
90
+ # nested scope-introducing constructs are not recursed into.
91
+ def find_nodes_in_scope(node, type, &block)
92
+ return unless node.is_a?(Parser::AST::Node)
93
+
94
+ yield(node) if node.type == type
95
+
96
+ node.children.each do |child|
97
+ next unless child.is_a?(Parser::AST::Node)
98
+
99
+ # Scope-boundary children get yielded if they match the target type,
100
+ # but we do not recurse into them.
101
+ if SCOPE_BOUNDARY_TYPES.include?(child.type)
102
+ yield(child) if child.type == type
103
+ next
104
+ end
105
+
106
+ find_nodes_in_scope(child, type, &block)
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ # The body of a `class` node is its third child; for `module`, the second.
113
+ # The body may be a single node or a `:begin` containing a sequence of nodes.
114
+ def class_body_children(class_or_module_node)
115
+ body =
116
+ case class_or_module_node.type
117
+ when :class then class_or_module_node.children[2]
118
+ when :module then class_or_module_node.children[1]
119
+ end
120
+
121
+ return [] if body.nil?
122
+ return body.children if body.is_a?(Parser::AST::Node) && body.type == :begin
123
+ [body]
124
+ end
125
+ end
126
+ end
@@ -64,6 +64,17 @@ 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
+ },
71
+ 'smell_thresholds' => {
72
+ 'god_class_lines' => 400,
73
+ 'god_class_methods' => 20,
74
+ 'high_complexity_method' => 15,
75
+ 'too_many_parameters' => 5,
76
+ 'nested_conditionals' => 4
77
+ },
67
78
  'file_type_multipliers' => {
68
79
  'controllers' => 1.2,
69
80
  'models' => 1.0,
@@ -71,7 +82,16 @@ module RailsCodeHealth
71
82
  'helpers' => 0.9,
72
83
  'lib' => 1.1,
73
84
  'specs' => 0.7,
74
- 'migrations' => 0.6
85
+ 'migrations' => 0.6,
86
+ 'service' => 1.3,
87
+ 'interactor' => 1.2,
88
+ 'serializer' => 0.9,
89
+ 'form' => 1.1,
90
+ 'decorator' => 0.9,
91
+ 'presenter' => 0.9,
92
+ 'policy' => 1.2,
93
+ 'job' => 1.0,
94
+ 'worker' => 1.0
75
95
  },
76
96
  'scoring_weights' => {
77
97
  '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 = []
@@ -88,7 +91,9 @@ module RailsCodeHealth
88
91
  files.concat(Dir.glob(pattern))
89
92
  end
90
93
 
91
- files.map { |f| Pathname.new(f) }
94
+ # Patterns overlap (`app/views/**/*.erb` and `app/**/views/**/*.erb` both
95
+ # match files under `app/views/...`); dedupe by absolute path.
96
+ files.uniq.map { |f| Pathname.new(f) }
92
97
  end
93
98
 
94
99
  def should_skip_file?(relative_path)
@@ -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,106 @@ 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. Use a flat (un-attenuated)
230
+ # penalty here because the `rails_conventions` weight (0.15) is sized for
231
+ # softer conventions; missing the core service contract is severe.
232
+ unless service_data[:has_call_method]
233
+ penalties << 3.0
234
+ end
235
+
236
+ # Too many dependencies
237
+ dependency_count = service_data[:dependencies]&.count || 0
238
+ service_thresholds = @thresholds['service_thresholds']['dependency_count']
239
+ if dependency_count > service_thresholds['yellow']
240
+ severity = dependency_count > service_thresholds['red'] ? 1.5 : 0.75
241
+ penalties << severity
242
+ end
243
+
244
+ # High complexity
245
+ complexity = service_data[:complexity_score] || 0
246
+ complexity_thresholds = @thresholds['service_thresholds']['complexity_score']
247
+ if complexity > complexity_thresholds['yellow']
248
+ severity = complexity > complexity_thresholds['red'] ? 2.0 : 1.0
249
+ penalties << severity
250
+ end
251
+
252
+ # Missing error handling
253
+ error_handling = service_data[:error_handling] || {}
254
+ unless error_handling[:has_error_handling]
255
+ penalties << 1.0 * @weights['rails_conventions']
256
+ end
257
+
258
+ penalties
259
+ end
260
+
261
+ def calculate_interactor_penalties(interactor_data)
262
+ penalties = []
263
+
264
+ # Missing call method - critical for interactors (flat penalty; see
265
+ # service equivalent for rationale).
266
+ unless interactor_data[:has_call_method]
267
+ penalties << 3.0
268
+ end
269
+
270
+ # Complex organizers
271
+ if interactor_data[:is_organizer]
272
+ complexity = interactor_data[:complexity_score] || 0
273
+ if complexity > 20 # Organizers should be simple orchestrators
274
+ penalties << 2.0
275
+ end
276
+ end
277
+
278
+ # Missing failure handling
279
+ fail_usage = interactor_data[:fail_usage] || {}
280
+ if (fail_usage[:context_fail] || 0) == 0 && (fail_usage[:fail_bang] || 0) == 0
281
+ penalties << 1.5 * @weights['rails_conventions']
282
+ end
283
+
284
+ # High complexity for regular interactors
285
+ unless interactor_data[:is_organizer]
286
+ complexity = interactor_data[:complexity_score] || 0
287
+ if complexity > 15
288
+ penalties << 1.5
289
+ end
290
+ end
291
+
292
+ penalties
293
+ end
294
+
295
+ def calculate_serializer_penalties(serializer_data)
296
+ penalties = []
297
+
298
+ # Too many attributes/associations (fat serializer). Flat penalties because
299
+ # `rails_conventions` weight (0.15) drowned out the fat signal.
300
+ attribute_count = serializer_data[:attribute_count] || 0
301
+ association_count = serializer_data[:association_count] || 0
302
+ total_fields = attribute_count + association_count
303
+
304
+ if total_fields > 20
305
+ penalties << 1.5
306
+ elsif total_fields > 15
307
+ penalties << 0.5
308
+ end
309
+
310
+ # Too many custom methods (complex logic)
311
+ custom_method_count = serializer_data[:custom_method_count] || 0
312
+ if custom_method_count > 10
313
+ penalties << 0.8
314
+ elsif custom_method_count > 5
315
+ penalties << 0.4
316
+ end
317
+
318
+ # Empty serializer (no value)
319
+ if total_fields == 0 && custom_method_count == 0
320
+ penalties << 1.0 * @weights['rails_conventions']
321
+ end
322
+
323
+ penalties
324
+ end
325
+
215
326
  def calculate_code_smell_penalties(file_result)
216
327
  penalties = []
217
328
 
@@ -360,6 +471,24 @@ module RailsCodeHealth
360
471
  recommendations << "Reduce model callbacks (#{smell[:count]} found) - consider service objects"
361
472
  when :data_changes_in_migration
362
473
  recommendations << "Avoid data changes in migrations - use rake tasks instead"
474
+ when :business_logic_in_controller
475
+ recommendations << "Extract business logic from controller to service objects"
476
+ when :missing_call_method
477
+ recommendations << "Add a call method to follow service/interactor conventions"
478
+ when :fat_service
479
+ recommendations << "Break down service into smaller, focused services (complexity: #{smell[:complexity]})"
480
+ when :missing_error_handling
481
+ recommendations << "Add proper error handling with rescue blocks"
482
+ when :complex_organizer
483
+ recommendations << "Simplify organizer - it should only orchestrate other interactors (complexity: #{smell[:complexity]})"
484
+ when :missing_failure_handling
485
+ recommendations << "Add failure handling using context.fail or fail! methods"
486
+ when :fat_serializer
487
+ recommendations << "Split serializer - it has #{smell[:field_count]} fields"
488
+ when :complex_serializer
489
+ recommendations << "Move complex logic out of serializer (#{smell[:method_count]} custom methods)"
490
+ when :empty_serializer
491
+ recommendations << "Add attributes or associations to provide value"
363
492
  end
364
493
  end
365
494
  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