rails-architect 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +309 -67
- data/config/rails_architect.yml +25 -0
- data/lib/rails_architect/dry_analyzer.rb +143 -0
- data/lib/rails_architect/kiss_analyzer.rb +130 -0
- data/lib/rails_architect/railtie.rb +11 -0
- data/lib/rails_architect/solid_analyzer.rb +196 -0
- data/lib/rails_architect/tasks/rails_architect.rake +35 -0
- data/lib/rails_architect/version.rb +5 -0
- data/lib/rails_architect.rb +30 -0
- data/test/rails_app/README.md +72 -0
- data/test/rails_app/app/controllers/posts_controller.rb +54 -0
- data/test/rails_app/app/controllers/users_controller.rb +104 -0
- data/test/rails_app/app/models/post.rb +34 -0
- data/test/rails_app/app/models/report_generator.rb +87 -0
- data/test/rails_app/app/models/user.rb +79 -0
- data/test/rails_app/config/rails_architect.yml +14 -0
- data/test/rails_app_integration_test.rb +50 -0
- data/test/rails_architect_test.rb +21 -0
- data/test/test_helper.rb +15 -0
- metadata +20 -7
- data/lib/rails/architect/version.rb +0 -7
- data/lib/rails/architect.rb +0 -10
- data/sig/rails/architect.rbs +0 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f5e214be6afcbf72e11d25f85d0a782160457961b4df66fcc108e0a578c4317e
|
|
4
|
+
data.tar.gz: 0f69f574aec7b0a9973aeca1b4a5ebdb2a91a989bc13f9359b53272452d38a42
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b949a221ba4c2598e562fa284df3c0d6edfbb42d44d8e2d1a5b3a10dc30af8b14d691d17009aa8f334a8cedd73ec8ee22e65e93f24e18042d475942d29ac773a
|
|
7
|
+
data.tar.gz: fd11b11531bbb73e33c4d6611be74ae22415da8c6cff04036904530a43bc383980b692ce00af11ebf73f7bcffd3172db7d1bd84668f402a454aa01f8ec54a5aa
|
data/README.md
CHANGED
|
@@ -2,15 +2,170 @@
|
|
|
2
2
|
|
|
3
3
|
A comprehensive architectural enforcement and code quality tool for Rails applications.
|
|
4
4
|
|
|
5
|
-
Rails Architect helps maintain clean Rails architecture by automatically
|
|
5
|
+
Rails Architect helps maintain clean Rails architecture by automatically enforcing software design principles and detecting common anti-patterns.
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
|
|
9
|
+
- **SOLID Principles**: Enforces Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion principles
|
|
10
|
+
- **KISS Principle**: Detects overly complex code and suggests simplification
|
|
11
|
+
- **DRY Principle**: Identifies code duplication and repeated patterns
|
|
12
|
+
- **Automated Checks**: Run as rake tasks or integrate into CI/CD pipelines
|
|
13
|
+
|
|
14
|
+
## SOLID Principles Guide
|
|
15
|
+
|
|
16
|
+
Here's a quick, Rails-flavored guide to the **SOLID** principles—with tiny examples and common pitfalls.
|
|
17
|
+
|
|
18
|
+
### S — Single Responsibility
|
|
19
|
+
|
|
20
|
+
**One class = one reason to change.**
|
|
21
|
+
Rails gotchas: "Fat models," callback soup, controllers doing business logic.
|
|
22
|
+
|
|
23
|
+
**Smell**
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
class Order < ApplicationRecord
|
|
27
|
+
after_create :send_receipt_email
|
|
28
|
+
def total_cents; ... complex tax/discount logic ... end
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Refactor**
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
# app/services/orders/calculate_total.rb
|
|
36
|
+
class Orders::CalculateTotal
|
|
37
|
+
def self.call(order) = ... # pure calc
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# app/jobs/order_receipt_job.rb
|
|
41
|
+
class OrderReceiptJob < ApplicationJob
|
|
42
|
+
def perform(order_id) = OrderMailer.receipt(Order.find(order_id)).deliver_now
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# app/models/order.rb
|
|
46
|
+
class Order < ApplicationRecord
|
|
47
|
+
def total_cents = Orders::CalculateTotal.call(self)
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Tip: prefer **service objects, form objects, query objects, mailer jobs** over callbacks.
|
|
52
|
+
|
|
53
|
+
### O — Open/Closed
|
|
54
|
+
|
|
55
|
+
**Open for extension, closed for modification.**
|
|
56
|
+
Use composition & small objects; avoid editing core classes for each new case.
|
|
57
|
+
|
|
58
|
+
**Example: pluggable pricing rules**
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
class PriceCalculator
|
|
62
|
+
def initialize(rules = [LoyaltyRule.new, CouponRule.new])
|
|
63
|
+
@rules = rules
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def call(order)
|
|
67
|
+
@rules.reduce(order.subtotal) { |total, rule| rule.apply(order, total) }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Add a new rule class, don't edit `PriceCalculator`.
|
|
73
|
+
|
|
74
|
+
### L — Liskov Substitution
|
|
75
|
+
|
|
76
|
+
**Subtypes must be usable wherever their base is.**
|
|
77
|
+
Rails gotcha: STI models or polymorphic interfaces that don't honor contracts.
|
|
78
|
+
|
|
79
|
+
**Bad**
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
class Payment < ApplicationRecord; end
|
|
83
|
+
class CreditCardPayment < Payment; def refund!; ...; end
|
|
84
|
+
class GiftCardPayment < Payment; end # no refund!
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Better (explicit interface)**
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
module Refundable
|
|
91
|
+
def refund! = raise NotImplementedError
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class CreditCardPayment < ApplicationRecord
|
|
95
|
+
include Refundable
|
|
96
|
+
def refund!; ... end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
class GiftCardPayment < ApplicationRecord
|
|
100
|
+
include Refundable
|
|
101
|
+
def refund!; ... end
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Or avoid STI; use separate models + shared interface modules and tests.
|
|
106
|
+
|
|
107
|
+
### I — Interface Segregation
|
|
108
|
+
|
|
109
|
+
**Clients shouldn't depend on methods they don't use.**
|
|
110
|
+
Rails tip: keep "narrow" objects per use-case: **FormObject**, **Presenter**, **Query**—not one god-service.
|
|
111
|
+
|
|
112
|
+
**Example**
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
# Too broad:
|
|
116
|
+
class AccountService; def create; def suspend; def invite; def export_csv; end; end
|
|
117
|
+
|
|
118
|
+
# Split:
|
|
119
|
+
class Accounts::Creator; end
|
|
120
|
+
class Accounts::Suspender; end
|
|
121
|
+
class Accounts::Inviter; end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
For APIs, expose slim endpoints; for views, use **ViewComponents/Presenters** instead of fat helpers.
|
|
125
|
+
|
|
126
|
+
### D — Dependency Inversion
|
|
127
|
+
|
|
128
|
+
**Depend on abstractions, not concretions.**
|
|
129
|
+
Rails tip: inject collaborators; don't hard-wire globals (e.g., a specific HTTP client).
|
|
130
|
+
|
|
131
|
+
**Example**
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
# app/services/notifications/send_sms.rb
|
|
135
|
+
class Notifications::SendSms
|
|
136
|
+
def initialize(client: Sms::TwilioClient.new) = @client = client
|
|
137
|
+
def call(to:, body:) = @client.deliver(to:, body:)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# test
|
|
141
|
+
fake = Class.new { def deliver(...) = true }.new
|
|
142
|
+
expect(Notifications::SendSms.new(client: fake).call(to: "...", body: "...")).to be true
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Common Rails patterns that support SOLID
|
|
146
|
+
|
|
147
|
+
* **Service objects** (`app/services/...`) for business actions.
|
|
148
|
+
* **Form objects** (e.g., `Reform`, or POROs with `ActiveModel::Model`) to keep controllers skinny.
|
|
149
|
+
* **Query objects** for complex scopes (compose instead of stuffing models).
|
|
150
|
+
* **Policies** (`Pundit`) for authorization logic (SRP).
|
|
151
|
+
* **ViewComponent** (or presenters) to isolate view behavior (ISP/SRP).
|
|
152
|
+
* **Background jobs** for side effects (mail, webhooks) instead of callbacks.
|
|
153
|
+
* **Adapters/ports** for external services (DIP).
|
|
154
|
+
|
|
155
|
+
### Anti-patterns to watch
|
|
156
|
+
|
|
157
|
+
* Heavy **`ActiveSupport::Concern`** mixing multiple responsibilities.
|
|
158
|
+
* Model callbacks triggering network calls (violates SRP & DIP).
|
|
159
|
+
* STI where subclasses violate the base contract (breaks LSP).
|
|
160
|
+
* "Helper modules" with dozens of unrelated methods (violates SRP/ISP).
|
|
161
|
+
|
|
162
|
+
### Tiny checklist (paste in your PR template)
|
|
163
|
+
|
|
164
|
+
* Does each class have **one reason to change**?
|
|
165
|
+
* If a new variant appears, can I **add a class** (not edit many)?
|
|
166
|
+
* Do polymorphic things **share a clear interface** and tests?
|
|
167
|
+
* Are public APIs **small and focused**?
|
|
168
|
+
* Can I **swap dependencies** in tests via initializer args?
|
|
14
169
|
|
|
15
170
|
## Installation
|
|
16
171
|
|
|
@@ -32,103 +187,190 @@ Or install it yourself as:
|
|
|
32
187
|
gem install rails-architect
|
|
33
188
|
```
|
|
34
189
|
|
|
35
|
-
|
|
190
|
+
## Usage
|
|
36
191
|
|
|
37
|
-
|
|
192
|
+
### Rake Tasks
|
|
38
193
|
|
|
39
|
-
|
|
40
|
-
defaults: &defaults
|
|
41
|
-
model_method_threshold: 10
|
|
42
|
-
controller_action_threshold: 5
|
|
43
|
-
method_complexity_threshold: 10
|
|
44
|
-
test_coverage_minimum: 80
|
|
194
|
+
Run individual principle checks in your Rails application:
|
|
45
195
|
|
|
46
|
-
|
|
47
|
-
|
|
196
|
+
```bash
|
|
197
|
+
bundle exec rake rails_architect:check_solid # Check SOLID principles
|
|
198
|
+
bundle exec rake rails_architect:check_kiss # Check KISS principle
|
|
199
|
+
bundle exec rake rails_architect:check_dry # Check DRY principle
|
|
200
|
+
```
|
|
48
201
|
|
|
49
|
-
|
|
50
|
-
<<: *defaults
|
|
202
|
+
Or with Docker:
|
|
51
203
|
|
|
52
|
-
|
|
53
|
-
|
|
204
|
+
```bash
|
|
205
|
+
docker exec your_container bundle exec rake rails_architect:check_solid
|
|
206
|
+
docker exec your_container bundle exec rake rails_architect:check_kiss
|
|
207
|
+
docker exec your_container bundle exec rake rails_architect:check_dry
|
|
54
208
|
```
|
|
55
209
|
|
|
56
|
-
###
|
|
210
|
+
### Example Test Application
|
|
57
211
|
|
|
58
|
-
|
|
212
|
+
The gem includes a test Rails application in `test/rails_app/` that demonstrates intentional violations of all principles:
|
|
59
213
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
214
|
+
**SOLID Violations:**
|
|
215
|
+
- **SRP**: `User` model has 15+ methods (fat model), `UsersController` has business logic and 9+ actions
|
|
216
|
+
- **OCP**: Deep inheritance chains and case statements that may need modification
|
|
217
|
+
- **LSP**: STI models and polymorphic associations that might violate contracts
|
|
218
|
+
- **ISP**: Heavy concerns and service objects with too many methods
|
|
219
|
+
- **DIP**: Hard-coded external service dependencies and network calls in callbacks
|
|
65
220
|
|
|
66
|
-
|
|
221
|
+
**KISS Violations:**
|
|
222
|
+
- **Complexity**: `ReportGenerator#generate_complex_report` has cyclomatic complexity of 14
|
|
223
|
+
- **Length**: Methods over 64 lines with deep nesting (5+ levels)
|
|
224
|
+
- **Class Size**: Large classes with multiple responsibilities
|
|
67
225
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
226
|
+
**DRY Violations:**
|
|
227
|
+
- **Code Duplication**: Identical method implementations across classes
|
|
228
|
+
- **Repeated Patterns**: Same filtering logic in multiple controller actions
|
|
229
|
+
- **String Literals**: Repeated long strings that should be constants
|
|
230
|
+
- **Method Calls**: Excessive calls to the same methods (e.g., `.count` called 7+ times)
|
|
231
|
+
|
|
232
|
+
**Configuration:**
|
|
233
|
+
See `test/rails_app/README.md` for detailed violation documentation.
|
|
234
|
+
|
|
235
|
+
## Detected Violations
|
|
236
|
+
|
|
237
|
+
### SOLID Principles
|
|
238
|
+
|
|
239
|
+
**Single Responsibility Principle (SRP):**
|
|
240
|
+
- Models with 15+ public methods (fat models)
|
|
241
|
+
- Controllers with 8+ actions (fat controllers)
|
|
242
|
+
- Models with 2+ callbacks (callback soup)
|
|
243
|
+
- Controllers doing business logic (calling services, mailers)
|
|
244
|
+
- Models with network calls in callbacks
|
|
245
|
+
|
|
246
|
+
**Open/Closed Principle (OCP):**
|
|
247
|
+
- Classes with inheritance depth > 3
|
|
248
|
+
- Complex case statements with 3+ conditions
|
|
249
|
+
|
|
250
|
+
**Liskov Substitution Principle (LSP):**
|
|
251
|
+
- STI models that may violate base contracts
|
|
252
|
+
- Polymorphic associations without clear interfaces
|
|
77
253
|
|
|
78
|
-
|
|
254
|
+
**Interface Segregation Principle (ISP):**
|
|
255
|
+
- Controllers including 3+ modules
|
|
256
|
+
- ActiveSupport::Concern modules with 5+ methods
|
|
257
|
+
- Service objects with 10+ methods
|
|
79
258
|
|
|
80
|
-
|
|
259
|
+
**Dependency Inversion Principle (DIP):**
|
|
260
|
+
- Direct instantiation of classes (`.new` calls > 2)
|
|
261
|
+
- Hard-coded external service dependencies
|
|
262
|
+
- Network calls in model callbacks
|
|
263
|
+
|
|
264
|
+
### KISS Principle
|
|
265
|
+
|
|
266
|
+
- Methods with cyclomatic complexity > 10
|
|
267
|
+
- Classes with > 100 lines
|
|
268
|
+
- Methods with > 64 lines
|
|
269
|
+
- Nesting depth > 5 levels
|
|
270
|
+
|
|
271
|
+
### DRY Principle
|
|
272
|
+
|
|
273
|
+
- Identical method implementations across classes
|
|
274
|
+
- String literals repeated > 2 times
|
|
275
|
+
- Method calls repeated > 5 times (e.g., `.count`, `.where`)
|
|
276
|
+
- Duplicate code patterns in controllers
|
|
277
|
+
|
|
278
|
+
## Development
|
|
279
|
+
|
|
280
|
+
After checking out the repo, run `bundle install` to install dependencies. Then, run `ruby -Ilib:test test/*_test.rb` to run the tests.
|
|
281
|
+
|
|
282
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
283
|
+
|
|
284
|
+
## Publishing and Releasing the Gem
|
|
285
|
+
|
|
286
|
+
### Prerequisites
|
|
287
|
+
|
|
288
|
+
1. **RubyGems Account**: Create an account at [rubygems.org](https://rubygems.org) if you don't have one
|
|
289
|
+
2. **API Credentials**: Ensure you're logged in locally:
|
|
290
|
+
```bash
|
|
291
|
+
gem signin
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Build the Gem
|
|
295
|
+
|
|
296
|
+
Build the gem package from the gemspec:
|
|
81
297
|
|
|
82
298
|
```bash
|
|
83
|
-
|
|
84
|
-
chmod +x .git/hooks/pre-commit
|
|
299
|
+
gem build rails-architect.gemspec
|
|
85
300
|
```
|
|
86
301
|
|
|
87
|
-
|
|
302
|
+
This creates a `.gem` file (e.g., `rails-architect-0.1.0.gem`) in your project root.
|
|
88
303
|
|
|
89
|
-
###
|
|
304
|
+
### Publishing to RubyGems
|
|
90
305
|
|
|
91
|
-
|
|
306
|
+
Push the built gem to RubyGems:
|
|
92
307
|
|
|
93
308
|
```bash
|
|
94
|
-
rails-architect
|
|
95
|
-
rails-architect visualize # Generate diagrams
|
|
309
|
+
gem push rails-architect-0.1.0.gem
|
|
96
310
|
```
|
|
97
311
|
|
|
98
|
-
|
|
312
|
+
Or use the rake task:
|
|
99
313
|
|
|
100
314
|
```bash
|
|
101
|
-
|
|
102
|
-
rake rails_architect:visualize # Generate visualizations
|
|
103
|
-
rake rails_architect:check_coverage # Check test coverage
|
|
104
|
-
rake rails_architect:check_performance # Run performance checks
|
|
315
|
+
bundle exec rake release
|
|
105
316
|
```
|
|
106
317
|
|
|
107
|
-
|
|
318
|
+
This rake task will:
|
|
319
|
+
- Build the gem
|
|
320
|
+
- Create a git tag for the version
|
|
321
|
+
- Push the tag to GitHub
|
|
322
|
+
- Push the gem to RubyGems
|
|
108
323
|
|
|
109
|
-
|
|
110
|
-
require 'rails_architect'
|
|
324
|
+
### Version Management
|
|
111
325
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
end
|
|
326
|
+
1. Update the version in `lib/rails_architect/version.rb`:
|
|
327
|
+
```ruby
|
|
328
|
+
module RailsArchitect
|
|
329
|
+
VERSION = "0.2.0"
|
|
330
|
+
end
|
|
331
|
+
```
|
|
117
332
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
333
|
+
2. Commit the version change:
|
|
334
|
+
```bash
|
|
335
|
+
git add lib/rails_architect/version.rb
|
|
336
|
+
git commit -m "Bump version to 0.2.0"
|
|
337
|
+
```
|
|
121
338
|
|
|
122
|
-
|
|
339
|
+
3. Create and push a git tag:
|
|
340
|
+
```bash
|
|
341
|
+
git tag v0.2.0
|
|
342
|
+
git push origin main --tags
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
4. Build and push the gem:
|
|
346
|
+
```bash
|
|
347
|
+
gem build rails-architect.gemspec
|
|
348
|
+
gem push rails-architect-0.2.0.gem
|
|
349
|
+
```
|
|
123
350
|
|
|
124
|
-
|
|
351
|
+
### Yanking a Release
|
|
125
352
|
|
|
126
|
-
|
|
353
|
+
If you need to remove a version from RubyGems:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
gem yank rails-architect -v 0.1.0
|
|
357
|
+
```
|
|
127
358
|
|
|
128
359
|
## Contributing
|
|
129
360
|
|
|
130
361
|
Bug reports and pull requests are welcome on GitHub at https://github.com/samydghim/rails-architect.
|
|
131
362
|
|
|
132
|
-
##
|
|
363
|
+
## Testing
|
|
364
|
+
|
|
365
|
+
The gem includes a comprehensive test suite using Minitest. Run tests with:
|
|
366
|
+
|
|
367
|
+
```bash
|
|
368
|
+
ruby -Ilib:test test/*_test.rb
|
|
369
|
+
```
|
|
133
370
|
|
|
134
|
-
|
|
371
|
+
Test coverage includes:
|
|
372
|
+
- SOLID principles validation (SRP, OCP, LSP, ISP, DIP)
|
|
373
|
+
- KISS principle enforcement (complexity, size, nesting)
|
|
374
|
+
- DRY principle detection (duplication, patterns, repetition)
|
|
375
|
+
- Integration tests with sample Rails app demonstrating violations
|
|
376
|
+
- Rake task execution and output formatting
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
defaults: &defaults
|
|
2
|
+
# Model complexity: 7 methods is more realistic for most Rails apps
|
|
3
|
+
model_method_threshold: 7
|
|
4
|
+
# Controller complexity: 5 actions keeps controllers focused
|
|
5
|
+
controller_action_threshold: 5
|
|
6
|
+
# Method complexity: 8 is industry standard (vs 10 which is too lenient)
|
|
7
|
+
method_complexity_threshold: 8
|
|
8
|
+
# Test coverage: 85% is a good minimum for quality assurance
|
|
9
|
+
test_coverage_minimum: 85
|
|
10
|
+
|
|
11
|
+
development:
|
|
12
|
+
<<: *defaults
|
|
13
|
+
# Development can be more lenient for rapid prototyping
|
|
14
|
+
model_method_threshold: 10
|
|
15
|
+
method_complexity_threshold: 10
|
|
16
|
+
|
|
17
|
+
test:
|
|
18
|
+
<<: *defaults
|
|
19
|
+
# Strict testing requirements for CI/CD
|
|
20
|
+
test_coverage_minimum: 90
|
|
21
|
+
|
|
22
|
+
production:
|
|
23
|
+
<<: *defaults
|
|
24
|
+
# Production should have highest quality standards
|
|
25
|
+
test_coverage_minimum: 90
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsArchitect
|
|
4
|
+
class DryAnalyzer
|
|
5
|
+
attr_reader :issues
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@issues = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def analyze
|
|
12
|
+
check_code_duplication
|
|
13
|
+
check_repeated_patterns
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def add_issue(file, type, message)
|
|
19
|
+
@issues << { file: file, type: type, message: message }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def check_code_duplication
|
|
25
|
+
files_content = {}
|
|
26
|
+
|
|
27
|
+
# Read all Ruby files
|
|
28
|
+
Dir.glob("app/**/*.rb").each do |file|
|
|
29
|
+
files_content[file] = File.read(file)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Check for duplicate methods
|
|
33
|
+
methods = extract_all_methods(files_content)
|
|
34
|
+
|
|
35
|
+
# Find duplicate method bodies
|
|
36
|
+
method_bodies = methods.values
|
|
37
|
+
duplicates = find_duplicates(method_bodies)
|
|
38
|
+
|
|
39
|
+
duplicates.each do |body, count|
|
|
40
|
+
if count > 1 && body.strip.length > 50 # Only report significant duplicates
|
|
41
|
+
duplicate_methods = methods.select { |_, b| b == body }.keys
|
|
42
|
+
classes = duplicate_methods.map { |m| m.split('#').first }.uniq
|
|
43
|
+
|
|
44
|
+
if classes.count > 1 # Only if duplicated across different classes
|
|
45
|
+
add_issue("multiple_files", "dry_duplication", "Code duplication detected: #{duplicate_methods.join(', ')} have identical implementations")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def check_repeated_patterns
|
|
52
|
+
Dir.glob("app/**/*.rb").each do |file|
|
|
53
|
+
content = File.read(file)
|
|
54
|
+
|
|
55
|
+
# Check for repeated string literals
|
|
56
|
+
strings = content.scan(/"([^"]{10,})"/).flatten + content.scan(/'([^']{10,})'/).flatten
|
|
57
|
+
string_counts = strings.tally
|
|
58
|
+
|
|
59
|
+
string_counts.each do |str, count|
|
|
60
|
+
if count > 2
|
|
61
|
+
class_name = extract_class_name(file)
|
|
62
|
+
add_issue(file, "dry_strings", "#{class_name} repeats string '#{str[0..30]}...' #{count} times, consider extracting constants")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check for repeated method calls
|
|
67
|
+
method_calls = content.scan(/\.(\w+)\b/).flatten
|
|
68
|
+
call_counts = method_calls.tally
|
|
69
|
+
|
|
70
|
+
call_counts.each do |method, count|
|
|
71
|
+
if count > 5 && method.length > 3
|
|
72
|
+
class_name = extract_class_name(file)
|
|
73
|
+
add_issue(file, "dry_calls", "#{class_name} calls .#{method} #{count} times, consider extracting helper method")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def extract_all_methods(files_content)
|
|
80
|
+
methods = {}
|
|
81
|
+
|
|
82
|
+
files_content.each do |file, content|
|
|
83
|
+
class_name = extract_class_name(file)
|
|
84
|
+
file_methods = extract_methods(content)
|
|
85
|
+
|
|
86
|
+
file_methods.each do |method_name, body|
|
|
87
|
+
full_name = "#{class_name}##{method_name}"
|
|
88
|
+
methods[full_name] = body.strip
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
methods
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def extract_methods(content)
|
|
96
|
+
methods = {}
|
|
97
|
+
current_method = nil
|
|
98
|
+
indent_level = 0
|
|
99
|
+
method_start = false
|
|
100
|
+
|
|
101
|
+
content.each_line do |line|
|
|
102
|
+
if line.match?(/^\s*def \w+/)
|
|
103
|
+
current_method = line.strip.match(/def (\w+)/)[1]
|
|
104
|
+
methods[current_method] = ""
|
|
105
|
+
method_start = true
|
|
106
|
+
indent_level = line.match(/^\s*/).to_s.length
|
|
107
|
+
elsif method_start && line.match(/^\s*end\s*$/) && line.match(/^\s*/).to_s.length == indent_level
|
|
108
|
+
method_start = false
|
|
109
|
+
current_method = nil
|
|
110
|
+
elsif method_start
|
|
111
|
+
methods[current_method] += line
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
methods
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def find_duplicates(array)
|
|
119
|
+
duplicates = {}
|
|
120
|
+
seen = {}
|
|
121
|
+
|
|
122
|
+
array.each do |item|
|
|
123
|
+
normalized = item.strip
|
|
124
|
+
if seen[normalized]
|
|
125
|
+
duplicates[normalized] = (duplicates[normalized] || 2) + 1
|
|
126
|
+
else
|
|
127
|
+
seen[normalized] = true
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
duplicates
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def extract_class_name(file)
|
|
135
|
+
relative_path = file.sub('app/', '').sub('.rb', '')
|
|
136
|
+
parts = relative_path.split('/')
|
|
137
|
+
class_parts = parts.map do |part|
|
|
138
|
+
part.split('_').map(&:capitalize).join
|
|
139
|
+
end
|
|
140
|
+
class_parts.join('::')
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|