layer_checker 1.0.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/LICENSE +21 -0
- data/README.md +313 -0
- data/bin/layer_checker +59 -0
- data/examples/conventional-layer.yml +25 -0
- data/examples/ddd-layer.yml +42 -0
- data/examples/featured-layer.yml +29 -0
- data/lib/layer_checker/code_analyzer.rb +63 -0
- data/lib/layer_checker/config.rb +63 -0
- data/lib/layer_checker/dependency_extractor.rb +88 -0
- data/lib/layer_checker/dependency_validator.rb +157 -0
- data/lib/layer_checker/module.rb +74 -0
- data/lib/layer_checker/parser.rb +0 -0
- data/lib/layer_checker/railtie.rb +29 -0
- data/lib/layer_checker/reporter.rb +95 -0
- data/lib/layer_checker/version.rb +3 -0
- data/lib/layer_checker/violation.rb +34 -0
- data/lib/layer_checker.rb +82 -0
- metadata +103 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 78b371df5e5e5da7832fe55c7c73118533e5ed0f58a81c210893b798c3e79cb5
|
|
4
|
+
data.tar.gz: 6c8061758737e375c24ac30f96a6bb266b5f03d2fbf75d22e72eb3d8540d21c0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 594f7ef3c58a15ae5cfaa153fa3f75141f8bf1411efacfd233751319782f81a591a97dd6252c1af23cb67f02f8f9f40c7019b9499f6b67709da2d2c1f434b082
|
|
7
|
+
data.tar.gz: 79d98cff723311e13d9239e5d7fbe50c5b6f85379f717212d5954d4c120ce1c99b700c8e2fb3a45a5c9ad0dab453dcc44ea7282a142410b50142409f39f1a336
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Victor Manuel Chaves Garcia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# Layer Checker
|
|
2
|
+
> This project was made with a little help of vibe-coding.
|
|
3
|
+
|
|
4
|
+
A Ruby gem to validate architectural layer dependencies in your projects based on YAML configuration files.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
Add this line to your application's Gemfile:
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
gem 'layer-checker'
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
And then execute:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bundle install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or install it yourself as:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
gem install layer-checker
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
### 1. Create a Configuration File
|
|
29
|
+
|
|
30
|
+
Create a `project_layers.yml` file in your project root:
|
|
31
|
+
|
|
32
|
+
```yaml
|
|
33
|
+
version: 1
|
|
34
|
+
|
|
35
|
+
modules:
|
|
36
|
+
controller:
|
|
37
|
+
paths:
|
|
38
|
+
- app/controllers/**
|
|
39
|
+
depends_on:
|
|
40
|
+
- service
|
|
41
|
+
- presenter
|
|
42
|
+
|
|
43
|
+
service:
|
|
44
|
+
paths:
|
|
45
|
+
- app/services/**
|
|
46
|
+
depends_on:
|
|
47
|
+
- model
|
|
48
|
+
|
|
49
|
+
model:
|
|
50
|
+
paths:
|
|
51
|
+
- app/models/**
|
|
52
|
+
depends_on: []
|
|
53
|
+
|
|
54
|
+
presenter:
|
|
55
|
+
paths:
|
|
56
|
+
- app/presenters/**
|
|
57
|
+
depends_on: []
|
|
58
|
+
|
|
59
|
+
options:
|
|
60
|
+
fail_on_violation: true
|
|
61
|
+
unassigned_behavior: warn
|
|
62
|
+
allow_external_constants:
|
|
63
|
+
- ActiveRecord::Base
|
|
64
|
+
- ApplicationController
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 2. Run the Checker
|
|
68
|
+
|
|
69
|
+
#### Command Line
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
layer_checker
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
With options:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
layer_checker --config custom_config.yml --format json
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
#### In Rails (Rake Task)
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
rake layer_checker:check
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
With environment variables:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
CONFIG=custom_config.yml FORMAT=json rake layer_checker:check
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
#### Programmatically
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
require 'layer_checker'
|
|
97
|
+
|
|
98
|
+
checker = LayerChecker::Checker.new(
|
|
99
|
+
config_path: 'project_layers.yml',
|
|
100
|
+
project_root: Dir.pwd
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
result = checker.check
|
|
104
|
+
checker.report(format: :console)
|
|
105
|
+
|
|
106
|
+
if result[:success]
|
|
107
|
+
puts "All good!"
|
|
108
|
+
else
|
|
109
|
+
puts "Found #{result[:violations].size} violations"
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Configuration
|
|
114
|
+
|
|
115
|
+
### Module Structure
|
|
116
|
+
|
|
117
|
+
#### Simple Modules
|
|
118
|
+
|
|
119
|
+
```yaml
|
|
120
|
+
modules:
|
|
121
|
+
controller:
|
|
122
|
+
paths:
|
|
123
|
+
- app/controllers/**
|
|
124
|
+
depends_on:
|
|
125
|
+
- service
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
#### Nested Modules (DDD Style)
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
modules:
|
|
132
|
+
billing:
|
|
133
|
+
modules:
|
|
134
|
+
domain:
|
|
135
|
+
paths:
|
|
136
|
+
- app/billing/domain/**
|
|
137
|
+
depends_on: []
|
|
138
|
+
|
|
139
|
+
application:
|
|
140
|
+
paths:
|
|
141
|
+
- app/billing/application/**
|
|
142
|
+
depends_on:
|
|
143
|
+
- domain
|
|
144
|
+
|
|
145
|
+
infrastructure:
|
|
146
|
+
paths:
|
|
147
|
+
- app/billing/infrastructure/**
|
|
148
|
+
depends_on:
|
|
149
|
+
- application
|
|
150
|
+
- domain
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
#### Feature-Based Modules
|
|
154
|
+
|
|
155
|
+
```yaml
|
|
156
|
+
modules:
|
|
157
|
+
authentication:
|
|
158
|
+
modules:
|
|
159
|
+
controller:
|
|
160
|
+
paths:
|
|
161
|
+
- app/authentication/controllers/**
|
|
162
|
+
depends_on:
|
|
163
|
+
- service
|
|
164
|
+
|
|
165
|
+
service:
|
|
166
|
+
paths:
|
|
167
|
+
- app/authentication/services/**
|
|
168
|
+
depends_on:
|
|
169
|
+
- model
|
|
170
|
+
|
|
171
|
+
model:
|
|
172
|
+
paths:
|
|
173
|
+
- app/authentication/models/**
|
|
174
|
+
depends_on: []
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Options
|
|
178
|
+
|
|
179
|
+
- **`fail_on_violation`** (boolean, default: `false`): Exit with error code 1 if violations are found
|
|
180
|
+
- **`unassigned_behavior`** (string, default: `warn`): How to handle files that don't belong to any module
|
|
181
|
+
- `warn`: Just warn about unassigned files
|
|
182
|
+
- `fail`: Treat unassigned files as violations
|
|
183
|
+
- `ignore`: Ignore unassigned files
|
|
184
|
+
- **`allow_external_constants`** (array, default: `[]`): List of external constants that are allowed (e.g., Rails framework classes)
|
|
185
|
+
|
|
186
|
+
## Examples
|
|
187
|
+
|
|
188
|
+
See the `examples/` directory for complete configuration examples:
|
|
189
|
+
|
|
190
|
+
- **[conventional-layer.yml](examples/conventional-layer.yml)**: Traditional MVC layered architecture
|
|
191
|
+
- **[ddd-layer.yml](examples/ddd-layer.yml)**: Domain-Driven Design with nested modules
|
|
192
|
+
- **[featured-layer.yml](examples/featured-layer.yml)**: Feature-based architecture
|
|
193
|
+
|
|
194
|
+
## How It Works
|
|
195
|
+
|
|
196
|
+
1. **Load Configuration**: Parses the YAML file to understand your architecture
|
|
197
|
+
2. **Scan Code**: Finds all Ruby files in your project
|
|
198
|
+
3. **Extract Dependencies**: Uses AST parsing to detect:
|
|
199
|
+
- Constant references (e.g., `UserService`, `User::Profile`)
|
|
200
|
+
- Method calls (e.g., `UserService.new`)
|
|
201
|
+
- Class inheritance (e.g., `class Controller < ApplicationController`)
|
|
202
|
+
4. **Validate**: Checks if each dependency is allowed according to your configuration
|
|
203
|
+
5. **Report**: Shows violations with file locations and line numbers
|
|
204
|
+
|
|
205
|
+
## Output Formats
|
|
206
|
+
|
|
207
|
+
### Console (Default)
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
================================================================================
|
|
211
|
+
Layer Checker Results
|
|
212
|
+
================================================================================
|
|
213
|
+
✗ Found 2 violation(s)
|
|
214
|
+
|
|
215
|
+
Violations found:
|
|
216
|
+
|
|
217
|
+
controller:
|
|
218
|
+
app/controllers/users_controller.rb:15
|
|
219
|
+
→ controller cannot depend on model
|
|
220
|
+
→ Referenced: User
|
|
221
|
+
|
|
222
|
+
service:
|
|
223
|
+
app/services/billing_service.rb:23
|
|
224
|
+
→ service cannot depend on presenter
|
|
225
|
+
→ Referenced: InvoicePresenter
|
|
226
|
+
|
|
227
|
+
================================================================================
|
|
228
|
+
Summary:
|
|
229
|
+
Total violations: 2
|
|
230
|
+
Modules affected: 2
|
|
231
|
+
================================================================================
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### JSON
|
|
235
|
+
|
|
236
|
+
```json
|
|
237
|
+
{
|
|
238
|
+
"success": false,
|
|
239
|
+
"message": "Found 2 violation(s)",
|
|
240
|
+
"violations": [
|
|
241
|
+
{
|
|
242
|
+
"source_file": "app/controllers/users_controller.rb",
|
|
243
|
+
"source_module": "controller",
|
|
244
|
+
"target_constant": "User",
|
|
245
|
+
"target_module": "model",
|
|
246
|
+
"line_number": 15,
|
|
247
|
+
"violation_type": "forbidden_dependency"
|
|
248
|
+
}
|
|
249
|
+
],
|
|
250
|
+
"summary": {
|
|
251
|
+
"total_violations": 2,
|
|
252
|
+
"modules_affected": 2
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## CLI Options
|
|
258
|
+
|
|
259
|
+
```
|
|
260
|
+
Usage: layer_checker [options]
|
|
261
|
+
-c, --config PATH Path to config file (default: project_layers.yml)
|
|
262
|
+
-p, --project PATH Project root directory (default: current directory)
|
|
263
|
+
-f, --format FORMAT Output format: console or json (default: console)
|
|
264
|
+
-h, --help Show this help message
|
|
265
|
+
-v, --version Show version
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Integration with CI/CD
|
|
269
|
+
|
|
270
|
+
Add to your CI pipeline:
|
|
271
|
+
|
|
272
|
+
```yaml
|
|
273
|
+
# .github/workflows/ci.yml
|
|
274
|
+
- name: Check Layer Architecture
|
|
275
|
+
run: bundle exec layer_checker
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Or with Rake:
|
|
279
|
+
|
|
280
|
+
```yaml
|
|
281
|
+
- name: Check Layer Architecture
|
|
282
|
+
run: bundle exec rake layer_checker:check
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Set `fail_on_violation: true` in your config to fail the build on violations.
|
|
286
|
+
|
|
287
|
+
## Development
|
|
288
|
+
|
|
289
|
+
After checking out the repo, run:
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
bundle install
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
To build the gem:
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
gem build layer_checker.gemspec
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
To install locally:
|
|
302
|
+
|
|
303
|
+
```bash
|
|
304
|
+
gem install ./layer-checker-0.1.0.gem
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
## Contributing
|
|
308
|
+
|
|
309
|
+
Bug reports and pull requests are welcome on GitHub.
|
|
310
|
+
|
|
311
|
+
## License
|
|
312
|
+
|
|
313
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE).
|
data/bin/layer_checker
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require_relative '../lib/layer_checker'
|
|
6
|
+
|
|
7
|
+
options = {
|
|
8
|
+
config_path: 'project_layers.yml',
|
|
9
|
+
project_root: Dir.pwd,
|
|
10
|
+
format: :console
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
OptionParser.new do |opts|
|
|
14
|
+
opts.banner = "Usage: layer_checker [options]"
|
|
15
|
+
|
|
16
|
+
opts.on("-c", "--config PATH", "Path to config file (default: project_layers.yml)") do |path|
|
|
17
|
+
options[:config_path] = path
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
opts.on("-p", "--project PATH", "Project root directory (default: current directory)") do |path|
|
|
21
|
+
options[:project_root] = path
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
opts.on("-f", "--format FORMAT", "Output format: console or json (default: console)") do |format|
|
|
25
|
+
options[:format] = format.to_sym
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
opts.on("-h", "--help", "Show this help message") do
|
|
29
|
+
puts opts
|
|
30
|
+
exit
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
opts.on("-v", "--version", "Show version") do
|
|
34
|
+
puts "LayerChecker version #{LayerChecker::VERSION}"
|
|
35
|
+
exit
|
|
36
|
+
end
|
|
37
|
+
end.parse!
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
checker = LayerChecker::Checker.new(
|
|
41
|
+
config_path: options[:config_path],
|
|
42
|
+
project_root: options[:project_root]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
result = checker.check
|
|
46
|
+
checker.report(format: options[:format])
|
|
47
|
+
|
|
48
|
+
# Exit with error code if violations found and fail_on_violation is true
|
|
49
|
+
if !result[:success] && checker.config.option('fail_on_violation', false)
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
|
52
|
+
rescue LayerChecker::ConfigError => e
|
|
53
|
+
puts "Configuration Error: #{e.message}"
|
|
54
|
+
exit 1
|
|
55
|
+
rescue => e
|
|
56
|
+
puts "Error: #{e.message}"
|
|
57
|
+
puts e.backtrace if ENV['DEBUG']
|
|
58
|
+
exit 1
|
|
59
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
|
|
3
|
+
modules:
|
|
4
|
+
controller:
|
|
5
|
+
paths:
|
|
6
|
+
- app/controllers/**
|
|
7
|
+
depends_on:
|
|
8
|
+
- service
|
|
9
|
+
- presenter
|
|
10
|
+
|
|
11
|
+
service:
|
|
12
|
+
paths:
|
|
13
|
+
- app/services/**
|
|
14
|
+
depends_on:
|
|
15
|
+
- model
|
|
16
|
+
|
|
17
|
+
model:
|
|
18
|
+
paths:
|
|
19
|
+
- app/models/**
|
|
20
|
+
depends_on: []
|
|
21
|
+
|
|
22
|
+
presenter:
|
|
23
|
+
paths:
|
|
24
|
+
- app/presenters/**
|
|
25
|
+
depends_on: []
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
|
|
3
|
+
modules:
|
|
4
|
+
billing:
|
|
5
|
+
modules:
|
|
6
|
+
domain:
|
|
7
|
+
paths:
|
|
8
|
+
- app/billing/domain/**
|
|
9
|
+
depends_on: []
|
|
10
|
+
|
|
11
|
+
application:
|
|
12
|
+
paths:
|
|
13
|
+
- app/billing/application/**
|
|
14
|
+
depends_on:
|
|
15
|
+
- domain
|
|
16
|
+
|
|
17
|
+
infrastructure:
|
|
18
|
+
paths:
|
|
19
|
+
- app/billing/infrastructure/**
|
|
20
|
+
depends_on:
|
|
21
|
+
- application
|
|
22
|
+
- domain
|
|
23
|
+
|
|
24
|
+
users:
|
|
25
|
+
modules:
|
|
26
|
+
domain:
|
|
27
|
+
paths:
|
|
28
|
+
- app/users/domain/**
|
|
29
|
+
depends_on: []
|
|
30
|
+
|
|
31
|
+
application:
|
|
32
|
+
paths:
|
|
33
|
+
- app/users/application/**
|
|
34
|
+
depends_on:
|
|
35
|
+
- domain
|
|
36
|
+
|
|
37
|
+
infrastructure:
|
|
38
|
+
paths:
|
|
39
|
+
- app/users/infrastructure/**
|
|
40
|
+
depends_on:
|
|
41
|
+
- application
|
|
42
|
+
- domain
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
|
|
3
|
+
modules:
|
|
4
|
+
authentication:
|
|
5
|
+
modules:
|
|
6
|
+
controller:
|
|
7
|
+
paths:
|
|
8
|
+
- app/authentication/controllers/**
|
|
9
|
+
depends_on:
|
|
10
|
+
- service
|
|
11
|
+
|
|
12
|
+
service:
|
|
13
|
+
paths:
|
|
14
|
+
- app/authentication/services/**
|
|
15
|
+
depends_on:
|
|
16
|
+
- model
|
|
17
|
+
|
|
18
|
+
model:
|
|
19
|
+
paths:
|
|
20
|
+
- app/authentication/models/**
|
|
21
|
+
depends_on: []
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
options:
|
|
25
|
+
fail_on_violation: true
|
|
26
|
+
unassigned_behavior: fail
|
|
27
|
+
allow_external_constants:
|
|
28
|
+
- ActiveRecord::Base
|
|
29
|
+
- ApplicationController
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'dependency_extractor'
|
|
4
|
+
|
|
5
|
+
module LayerChecker
|
|
6
|
+
class CodeAnalyzer
|
|
7
|
+
attr_reader :config, :project_root, :file_dependencies
|
|
8
|
+
|
|
9
|
+
def initialize(config, project_root: Dir.pwd)
|
|
10
|
+
@config = config
|
|
11
|
+
@project_root = project_root
|
|
12
|
+
@file_dependencies = {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Scan all Ruby files in the project
|
|
16
|
+
def analyze
|
|
17
|
+
ruby_files = find_ruby_files
|
|
18
|
+
|
|
19
|
+
ruby_files.each do |file_path|
|
|
20
|
+
relative_path = file_path.sub("#{@project_root}/", '')
|
|
21
|
+
dependencies = DependencyExtractor.extract(file_path)
|
|
22
|
+
|
|
23
|
+
@file_dependencies[relative_path] = {
|
|
24
|
+
absolute_path: file_path,
|
|
25
|
+
module: @config.module_for_file(relative_path),
|
|
26
|
+
dependencies: dependencies
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@file_dependencies
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Get all files that belong to a specific module
|
|
34
|
+
def files_for_module(mod)
|
|
35
|
+
@file_dependencies.select { |_, info| info[:module] == mod }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get files that don't belong to any module
|
|
39
|
+
def unassigned_files
|
|
40
|
+
@file_dependencies.select { |_, info| info[:module].nil? }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def find_ruby_files
|
|
46
|
+
# Find all .rb files, excluding common directories to ignore
|
|
47
|
+
exclude_patterns = %w[
|
|
48
|
+
**/vendor/**
|
|
49
|
+
**/node_modules/**
|
|
50
|
+
**/tmp/**
|
|
51
|
+
**/log/**
|
|
52
|
+
**/coverage/**
|
|
53
|
+
**/.git/**
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
all_files = Dir.glob("#{@project_root}/**/*.rb")
|
|
57
|
+
|
|
58
|
+
all_files.reject do |file|
|
|
59
|
+
exclude_patterns.any? { |pattern| File.fnmatch?(pattern, file, File::FNM_PATHNAME) }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require_relative 'module'
|
|
5
|
+
|
|
6
|
+
module LayerChecker
|
|
7
|
+
class Config
|
|
8
|
+
attr_reader :modules, :options, :all_modules
|
|
9
|
+
|
|
10
|
+
def initialize(config_path)
|
|
11
|
+
config = load_config(config_path)
|
|
12
|
+
|
|
13
|
+
@modules = parse_modules(config['modules'] || {})
|
|
14
|
+
@options = parse_options(config['options'] || {})
|
|
15
|
+
@all_modules = build_all_modules_map
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Find which module a file belongs to
|
|
19
|
+
def module_for_file(file_path)
|
|
20
|
+
@all_modules.values.find { |mod| mod.matches_file?(file_path) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Get option value with default
|
|
24
|
+
def option(key, default = nil)
|
|
25
|
+
@options.fetch(key, default)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def load_config(path)
|
|
31
|
+
raise "Config file not found: #{path}" unless File.exist?(path)
|
|
32
|
+
|
|
33
|
+
YAML.load_file(path)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def parse_modules(modules_config)
|
|
37
|
+
modules = {}
|
|
38
|
+
|
|
39
|
+
modules_config.each do |mod_name, mod_config|
|
|
40
|
+
modules[mod_name] = LayerChecker::Module.new(mod_name, mod_config)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
modules
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse_options(options_config)
|
|
47
|
+
{
|
|
48
|
+
'fail_on_violation' => options_config['fail_on_violation'] || false,
|
|
49
|
+
'unassigned_behavior' => options_config['unassigned_behavior'] || 'warn',
|
|
50
|
+
'allow_external_constants' => Array(options_config['allow_external_constants'] || [])
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Build a flat map of all modules (including nested ones)
|
|
55
|
+
def build_all_modules_map
|
|
56
|
+
result = {}
|
|
57
|
+
@modules.each_value do |mod|
|
|
58
|
+
result.merge!(mod.all_modules)
|
|
59
|
+
end
|
|
60
|
+
result
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'parser/current'
|
|
4
|
+
|
|
5
|
+
module LayerChecker
|
|
6
|
+
class DependencyExtractor
|
|
7
|
+
# Extract dependencies from a Ruby file
|
|
8
|
+
def self.extract(file_path)
|
|
9
|
+
return [] unless File.exist?(file_path)
|
|
10
|
+
|
|
11
|
+
source = File.read(file_path)
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
begin
|
|
15
|
+
buffer = Parser::Source::Buffer.new(file_path)
|
|
16
|
+
buffer.source = source
|
|
17
|
+
|
|
18
|
+
parser = Parser::CurrentRuby.new
|
|
19
|
+
ast = parser.parse(buffer)
|
|
20
|
+
|
|
21
|
+
dependencies = extract_from_ast(ast) if ast
|
|
22
|
+
rescue Parser::SyntaxError => e
|
|
23
|
+
# Skip files with syntax errors
|
|
24
|
+
warn "Syntax error in #{file_path}: #{e.message}" if ENV['DEBUG']
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
dependencies.uniq
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def self.extract_from_ast(node, dependencies = [])
|
|
33
|
+
return dependencies unless node.is_a?(Parser::AST::Node)
|
|
34
|
+
|
|
35
|
+
case node.type
|
|
36
|
+
when :const
|
|
37
|
+
# Extract constant references (e.g., UserService, User::Profile)
|
|
38
|
+
const_name = extract_const_name(node)
|
|
39
|
+
dependencies << { type: :constant, name: const_name, node: node } if const_name
|
|
40
|
+
|
|
41
|
+
when :send
|
|
42
|
+
# Extract method calls that might reference other modules
|
|
43
|
+
# e.g., UserService.new, User.find(1)
|
|
44
|
+
if node.children[0].is_a?(Parser::AST::Node) && node.children[0].type == :const
|
|
45
|
+
const_name = extract_const_name(node.children[0])
|
|
46
|
+
dependencies << { type: :method_call, name: const_name, node: node } if const_name
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
when :class, :module
|
|
50
|
+
# Extract class/module inheritance and includes
|
|
51
|
+
if node.children[1] # superclass
|
|
52
|
+
if node.children[1].is_a?(Parser::AST::Node) && node.children[1].type == :const
|
|
53
|
+
const_name = extract_const_name(node.children[1])
|
|
54
|
+
dependencies << { type: :inheritance, name: const_name, node: node.children[1] } if const_name
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Recursively process child nodes
|
|
60
|
+
node.children.each do |child|
|
|
61
|
+
extract_from_ast(child, dependencies) if child.is_a?(Parser::AST::Node)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
dependencies
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.extract_const_name(node)
|
|
68
|
+
return nil unless node.is_a?(Parser::AST::Node) && node.type == :const
|
|
69
|
+
|
|
70
|
+
parts = []
|
|
71
|
+
current = node
|
|
72
|
+
|
|
73
|
+
while current && current.is_a?(Parser::AST::Node)
|
|
74
|
+
if current.type == :const
|
|
75
|
+
parts.unshift(current.children[1].to_s)
|
|
76
|
+
current = current.children[0]
|
|
77
|
+
elsif current.type == :cbase
|
|
78
|
+
# Absolute constant (::Something)
|
|
79
|
+
break
|
|
80
|
+
else
|
|
81
|
+
break
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
parts.join('::')
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'violation'
|
|
4
|
+
|
|
5
|
+
module LayerChecker
|
|
6
|
+
class DependencyValidator
|
|
7
|
+
attr_reader :config, :analyzer, :violations
|
|
8
|
+
|
|
9
|
+
def initialize(config, analyzer)
|
|
10
|
+
@config = config
|
|
11
|
+
@analyzer = analyzer
|
|
12
|
+
@violations = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def validate
|
|
16
|
+
@violations = []
|
|
17
|
+
|
|
18
|
+
@analyzer.file_dependencies.each do |file_path, file_info|
|
|
19
|
+
source_module = file_info[:module]
|
|
20
|
+
|
|
21
|
+
# Handle unassigned files
|
|
22
|
+
if source_module.nil?
|
|
23
|
+
handle_unassigned_file(file_path, file_info)
|
|
24
|
+
next
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Check each dependency
|
|
28
|
+
file_info[:dependencies].each do |dependency|
|
|
29
|
+
check_dependency(file_path, source_module, dependency)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Deduplicate violations (same file, line, source and target)
|
|
34
|
+
@violations.uniq! do |v|
|
|
35
|
+
[v.source_file, v.line_number, v.source_module&.full_name, v.target_module&.full_name]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@violations
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def handle_unassigned_file(file_path, file_info)
|
|
44
|
+
behavior = @config.option('unassigned_behavior', 'warn')
|
|
45
|
+
|
|
46
|
+
if behavior == 'fail' && file_info[:dependencies].any?
|
|
47
|
+
@violations << Violation.new(
|
|
48
|
+
source_file: file_path,
|
|
49
|
+
source_module: nil,
|
|
50
|
+
target_constant: 'N/A',
|
|
51
|
+
target_module: nil,
|
|
52
|
+
violation_type: :unassigned_file
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def check_dependency(file_path, source_module, dependency)
|
|
58
|
+
const_name = dependency[:name]
|
|
59
|
+
|
|
60
|
+
# Skip if it's an allowed external constant
|
|
61
|
+
return if allowed_external_constant?(const_name)
|
|
62
|
+
|
|
63
|
+
# Find which module this constant belongs to
|
|
64
|
+
target_module = find_module_for_constant(const_name)
|
|
65
|
+
|
|
66
|
+
# If we can't find the module, it might be external (gem, Rails, etc.)
|
|
67
|
+
return unless target_module
|
|
68
|
+
|
|
69
|
+
# Skip if it's the same module
|
|
70
|
+
return if target_module == source_module
|
|
71
|
+
|
|
72
|
+
# Check if this dependency is allowed
|
|
73
|
+
unless dependency_allowed?(source_module, target_module)
|
|
74
|
+
@violations << Violation.new(
|
|
75
|
+
source_file: file_path,
|
|
76
|
+
source_module: source_module,
|
|
77
|
+
target_constant: const_name,
|
|
78
|
+
target_module: target_module,
|
|
79
|
+
line_number: extract_line_number(dependency[:node]),
|
|
80
|
+
violation_type: :forbidden_dependency
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def allowed_external_constant?(const_name)
|
|
86
|
+
allowed = @config.option('allow_external_constants', [])
|
|
87
|
+
allowed.any? { |pattern| const_name.start_with?(pattern) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def find_module_for_constant(const_name)
|
|
91
|
+
# Try to find a file that defines this constant
|
|
92
|
+
# Convert constant name to possible file paths
|
|
93
|
+
# e.g., UserService -> user_service.rb, User::Profile -> user/profile.rb
|
|
94
|
+
possible_file_names = constant_to_file_names(const_name)
|
|
95
|
+
|
|
96
|
+
# Look for files that match the constant name
|
|
97
|
+
matching_files = []
|
|
98
|
+
|
|
99
|
+
@analyzer.file_dependencies.each do |file_path, file_info|
|
|
100
|
+
next unless file_info[:module]
|
|
101
|
+
|
|
102
|
+
# Check if this file likely defines the constant
|
|
103
|
+
file_basename = File.basename(file_path, '.rb')
|
|
104
|
+
|
|
105
|
+
if possible_file_names.include?(file_basename)
|
|
106
|
+
matching_files << file_info
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Return the module of the first matching file
|
|
111
|
+
# (in most cases there should only be one)
|
|
112
|
+
matching_files.first&.dig(:module)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def constant_to_file_names(const_name)
|
|
116
|
+
# Convert CamelCase to snake_case
|
|
117
|
+
parts = const_name.split('::')
|
|
118
|
+
snake_parts = parts.map { |part| camel_to_snake(part) }
|
|
119
|
+
|
|
120
|
+
# Return possible file names
|
|
121
|
+
[
|
|
122
|
+
snake_parts.last, # Just the class name
|
|
123
|
+
snake_parts.join('_') # Full namespaced name
|
|
124
|
+
].uniq
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def camel_to_snake(str)
|
|
128
|
+
str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
129
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
130
|
+
.downcase
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def dependency_allowed?(source_module, target_module)
|
|
134
|
+
allowed_deps = source_module.allowed_dependencies
|
|
135
|
+
target_name = target_module.full_name
|
|
136
|
+
|
|
137
|
+
# Check direct match
|
|
138
|
+
return true if allowed_deps.include?(target_name)
|
|
139
|
+
|
|
140
|
+
# Check if target is in the allowed list (by simple name)
|
|
141
|
+
return true if allowed_deps.include?(target_module.name)
|
|
142
|
+
|
|
143
|
+
# Check parent module dependencies for nested modules
|
|
144
|
+
if source_module.parent
|
|
145
|
+
parent_allowed = source_module.parent.allowed_dependencies
|
|
146
|
+
return true if parent_allowed.include?(target_name) || parent_allowed.include?(target_module.name)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
false
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def extract_line_number(node)
|
|
153
|
+
return nil unless node.respond_to?(:loc) && node.loc.respond_to?(:line)
|
|
154
|
+
node.loc.line
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LayerChecker
|
|
4
|
+
class Module
|
|
5
|
+
attr_reader :name, :paths, :depends_on, :submodules, :parent
|
|
6
|
+
|
|
7
|
+
def initialize(name, config = {}, parent: nil)
|
|
8
|
+
@name = name
|
|
9
|
+
@parent = parent
|
|
10
|
+
@paths = Array(config['paths'] || [])
|
|
11
|
+
@depends_on = Array(config['depends_on'] || [])
|
|
12
|
+
@submodules = {}
|
|
13
|
+
|
|
14
|
+
# Parse nested modules if they exist
|
|
15
|
+
if config['modules']
|
|
16
|
+
config['modules'].each do |submodule_name, submodule_config|
|
|
17
|
+
@submodules[submodule_name] = Module.new(submodule_name, submodule_config, parent: self)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Check if a file path belongs to this module
|
|
23
|
+
def matches_file?(file_path)
|
|
24
|
+
@paths.any? { |pattern| File.fnmatch?(pattern, file_path, File::FNM_PATHNAME) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Get the full qualified name (e.g., "billing.domain")
|
|
28
|
+
def full_name
|
|
29
|
+
parent ? "#{parent.full_name}.#{name}" : name
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Get all modules (including nested ones) as a flat hash
|
|
33
|
+
def all_modules
|
|
34
|
+
result = { full_name => self }
|
|
35
|
+
@submodules.each_value do |submodule|
|
|
36
|
+
result.merge!(submodule.all_modules)
|
|
37
|
+
end
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Check if this module can depend on another module
|
|
42
|
+
def can_depend_on?(other_module_name)
|
|
43
|
+
# Direct dependency check
|
|
44
|
+
return true if @depends_on.include?(other_module_name)
|
|
45
|
+
|
|
46
|
+
# Check if it's a sibling module (same parent)
|
|
47
|
+
if parent
|
|
48
|
+
sibling_names = parent.submodules.keys
|
|
49
|
+
return @depends_on.any? { |dep| sibling_names.include?(dep) && dep == other_module_name }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Get all allowed dependencies (resolving relative names)
|
|
56
|
+
def allowed_dependencies
|
|
57
|
+
return @depends_on unless parent
|
|
58
|
+
|
|
59
|
+
# For nested modules, resolve dependencies relative to parent
|
|
60
|
+
@depends_on.map do |dep|
|
|
61
|
+
# If it's a simple name, it refers to a sibling
|
|
62
|
+
if parent.submodules.key?(dep)
|
|
63
|
+
"#{parent.full_name}.#{dep}"
|
|
64
|
+
else
|
|
65
|
+
dep
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_s
|
|
71
|
+
full_name
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LayerChecker
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
railtie_name :layer_checker
|
|
6
|
+
|
|
7
|
+
rake_tasks do
|
|
8
|
+
namespace :layer_checker do
|
|
9
|
+
desc "Check layer architecture compliance"
|
|
10
|
+
task check: :environment do
|
|
11
|
+
config_path = ENV['CONFIG'] || 'project_layers.yml'
|
|
12
|
+
format = ENV['FORMAT'] || 'console'
|
|
13
|
+
|
|
14
|
+
checker = LayerChecker::Checker.new(
|
|
15
|
+
config_path: config_path,
|
|
16
|
+
project_root: Rails.root.to_s
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
result = checker.check
|
|
20
|
+
checker.report(format: format.to_sym)
|
|
21
|
+
|
|
22
|
+
if !result[:success] && checker.config.option('fail_on_violation', false)
|
|
23
|
+
abort "Layer violations found!"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LayerChecker
|
|
4
|
+
class Reporter
|
|
5
|
+
COLORS = {
|
|
6
|
+
red: "\e[31m",
|
|
7
|
+
green: "\e[32m",
|
|
8
|
+
yellow: "\e[33m",
|
|
9
|
+
blue: "\e[34m",
|
|
10
|
+
reset: "\e[0m"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def self.report(result, format: :console)
|
|
14
|
+
case format
|
|
15
|
+
when :console
|
|
16
|
+
console_report(result)
|
|
17
|
+
when :json
|
|
18
|
+
json_report(result)
|
|
19
|
+
else
|
|
20
|
+
console_report(result)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.console_report(result)
|
|
25
|
+
puts "\n" + "=" * 80
|
|
26
|
+
puts colorize("Layer Checker Results", :blue)
|
|
27
|
+
puts "=" * 80 + "\n"
|
|
28
|
+
|
|
29
|
+
if result[:success]
|
|
30
|
+
puts colorize("✓ #{result[:message]}", :green)
|
|
31
|
+
else
|
|
32
|
+
puts colorize("✗ #{result[:message]}", :red)
|
|
33
|
+
puts
|
|
34
|
+
|
|
35
|
+
if result[:violations].any?
|
|
36
|
+
puts colorize("Violations found:", :yellow)
|
|
37
|
+
puts
|
|
38
|
+
|
|
39
|
+
# Group violations by source module
|
|
40
|
+
grouped = result[:violations].group_by { |v| v.to_h[:source_module] }
|
|
41
|
+
|
|
42
|
+
grouped.each do |source_module, violations|
|
|
43
|
+
module_name = source_module || "Unassigned files"
|
|
44
|
+
puts colorize(" #{module_name}:", :yellow)
|
|
45
|
+
|
|
46
|
+
violations.each do |violation|
|
|
47
|
+
puts " #{format_violation(violation)}"
|
|
48
|
+
end
|
|
49
|
+
puts
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Summary
|
|
53
|
+
puts "=" * 80
|
|
54
|
+
puts colorize("Summary:", :blue)
|
|
55
|
+
puts " Total violations: #{colorize(result[:violations].size.to_s, :red)}"
|
|
56
|
+
puts " Modules affected: #{colorize(grouped.keys.size.to_s, :yellow)}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
puts "=" * 80 + "\n"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.json_report(result)
|
|
64
|
+
require 'json'
|
|
65
|
+
|
|
66
|
+
output = {
|
|
67
|
+
success: result[:success],
|
|
68
|
+
message: result[:message],
|
|
69
|
+
violations: result[:violations].map(&:to_h),
|
|
70
|
+
summary: {
|
|
71
|
+
total_violations: result[:violations].size,
|
|
72
|
+
modules_affected: result[:violations].map { |v| v.to_h[:source_module] }.uniq.size
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
puts JSON.pretty_generate(output)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def self.format_violation(violation)
|
|
82
|
+
v = violation.to_h
|
|
83
|
+
location = v[:line_number] ? "#{v[:source_file]}:#{v[:line_number]}" : v[:source_file]
|
|
84
|
+
|
|
85
|
+
"#{location}\n" \
|
|
86
|
+
" → #{v[:source_module]} cannot depend on #{v[:target_module]}\n" \
|
|
87
|
+
" → Referenced: #{colorize(v[:target_constant], :red)}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.colorize(text, color)
|
|
91
|
+
return text unless $stdout.tty?
|
|
92
|
+
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LayerChecker
|
|
4
|
+
class Violation
|
|
5
|
+
attr_reader :source_file, :source_module, :target_constant,
|
|
6
|
+
:target_module, :line_number, :violation_type
|
|
7
|
+
|
|
8
|
+
def initialize(source_file:, source_module:, target_constant:,
|
|
9
|
+
target_module:, line_number: nil, violation_type: :dependency)
|
|
10
|
+
@source_file = source_file
|
|
11
|
+
@source_module = source_module
|
|
12
|
+
@target_constant = target_constant
|
|
13
|
+
@target_module = target_module
|
|
14
|
+
@line_number = line_number
|
|
15
|
+
@violation_type = violation_type
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
location = @line_number ? "#{@source_file}:#{@line_number}" : @source_file
|
|
20
|
+
"#{location}: #{@source_module} cannot depend on #{@target_module} (#{@target_constant})"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
{
|
|
25
|
+
source_file: @source_file,
|
|
26
|
+
source_module: @source_module&.to_s,
|
|
27
|
+
target_constant: @target_constant,
|
|
28
|
+
target_module: @target_module&.to_s,
|
|
29
|
+
line_number: @line_number,
|
|
30
|
+
violation_type: @violation_type
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'layer_checker/version'
|
|
4
|
+
require 'layer_checker/config'
|
|
5
|
+
require 'layer_checker/module'
|
|
6
|
+
require 'layer_checker/code_analyzer'
|
|
7
|
+
require 'layer_checker/dependency_validator'
|
|
8
|
+
require 'layer_checker/violation'
|
|
9
|
+
require 'layer_checker/reporter'
|
|
10
|
+
|
|
11
|
+
module LayerChecker
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
class ConfigError < Error; end
|
|
14
|
+
class ValidationError < Error; end
|
|
15
|
+
|
|
16
|
+
class Checker
|
|
17
|
+
attr_reader :config, :analyzer, :validator, :violations
|
|
18
|
+
|
|
19
|
+
def initialize(config_path: 'project_layers.yml', project_root: Dir.pwd)
|
|
20
|
+
@config_path = config_path
|
|
21
|
+
@project_root = project_root
|
|
22
|
+
@config = nil
|
|
23
|
+
@analyzer = nil
|
|
24
|
+
@validator = nil
|
|
25
|
+
@violations = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def check
|
|
29
|
+
load_configuration
|
|
30
|
+
analyze_code
|
|
31
|
+
validate_dependencies
|
|
32
|
+
|
|
33
|
+
build_result
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def report(format: :console)
|
|
37
|
+
result = build_result
|
|
38
|
+
Reporter.report(result, format: format)
|
|
39
|
+
result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def load_configuration
|
|
45
|
+
config_file = File.join(@project_root, @config_path)
|
|
46
|
+
@config = Config.new(config_file)
|
|
47
|
+
rescue => e
|
|
48
|
+
raise ConfigError, "Failed to load configuration: #{e.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def analyze_code
|
|
52
|
+
@analyzer = CodeAnalyzer.new(@config, project_root: @project_root)
|
|
53
|
+
@analyzer.analyze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_dependencies
|
|
57
|
+
@validator = DependencyValidator.new(@config, @analyzer)
|
|
58
|
+
@violations = @validator.validate
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_result
|
|
62
|
+
if @violations.empty?
|
|
63
|
+
{ success: true, message: "All layers are respected!", violations: [] }
|
|
64
|
+
else
|
|
65
|
+
{
|
|
66
|
+
success: false,
|
|
67
|
+
message: "Found #{@violations.size} violation(s)",
|
|
68
|
+
violations: @violations
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Convenience method for quick checks
|
|
75
|
+
def self.check(config_path: 'project_layers.yml', project_root: Dir.pwd)
|
|
76
|
+
checker = Checker.new(config_path: config_path, project_root: project_root)
|
|
77
|
+
checker.check
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Load Railtie if Rails is available
|
|
82
|
+
require 'layer_checker/railtie' if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: layer_checker
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Víctor Manuel Chaves García
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-01-25 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: parser
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '3.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '3.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rspec
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.0'
|
|
55
|
+
description: A Ruby gem to validate architectural layer dependencies based on YAML
|
|
56
|
+
configuration
|
|
57
|
+
email: victormcg96@gmail.com
|
|
58
|
+
executables:
|
|
59
|
+
- layer_checker
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- LICENSE
|
|
64
|
+
- README.md
|
|
65
|
+
- bin/layer_checker
|
|
66
|
+
- examples/conventional-layer.yml
|
|
67
|
+
- examples/ddd-layer.yml
|
|
68
|
+
- examples/featured-layer.yml
|
|
69
|
+
- lib/layer_checker.rb
|
|
70
|
+
- lib/layer_checker/code_analyzer.rb
|
|
71
|
+
- lib/layer_checker/config.rb
|
|
72
|
+
- lib/layer_checker/dependency_extractor.rb
|
|
73
|
+
- lib/layer_checker/dependency_validator.rb
|
|
74
|
+
- lib/layer_checker/module.rb
|
|
75
|
+
- lib/layer_checker/parser.rb
|
|
76
|
+
- lib/layer_checker/railtie.rb
|
|
77
|
+
- lib/layer_checker/reporter.rb
|
|
78
|
+
- lib/layer_checker/version.rb
|
|
79
|
+
- lib/layer_checker/violation.rb
|
|
80
|
+
homepage: https://rubygems.org/gems/layer-checker
|
|
81
|
+
licenses:
|
|
82
|
+
- MIT
|
|
83
|
+
metadata: {}
|
|
84
|
+
post_install_message:
|
|
85
|
+
rdoc_options: []
|
|
86
|
+
require_paths:
|
|
87
|
+
- lib
|
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: 2.7.0
|
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
94
|
+
requirements:
|
|
95
|
+
- - ">="
|
|
96
|
+
- !ruby/object:Gem::Version
|
|
97
|
+
version: '0'
|
|
98
|
+
requirements: []
|
|
99
|
+
rubygems_version: 3.5.19
|
|
100
|
+
signing_key:
|
|
101
|
+
specification_version: 4
|
|
102
|
+
summary: Check project layer architecture compliance
|
|
103
|
+
test_files: []
|