rubyzen-lint 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/CHANGELOG.md +24 -0
- data/README.md +57 -9
- data/lib/rubyzen/assertions/assert_zen_empty.rb +51 -0
- data/lib/rubyzen/assertions/assert_zen_false.rb +49 -0
- data/lib/rubyzen/assertions/assert_zen_true.rb +49 -0
- data/lib/rubyzen/assertions/zen_assertions.rb +29 -0
- data/lib/rubyzen/cache/parse_cache.rb +1 -0
- data/lib/rubyzen/collections/base_collection.rb +1 -0
- data/lib/rubyzen/core.rb +114 -0
- data/lib/rubyzen/declarations/file_declaration.rb +1 -0
- data/lib/rubyzen/expectation_helpers.rb +184 -0
- data/lib/rubyzen/matchers/zen_empty_matcher.rb +22 -11
- data/lib/rubyzen/matchers/zen_false_matcher.rb +22 -14
- data/lib/rubyzen/matchers/zen_true_matcher.rb +19 -8
- data/lib/rubyzen/minitest.rb +33 -0
- data/lib/rubyzen/parsers/a_s_t_parser.rb +1 -0
- data/lib/rubyzen/providers/blocks_provider.rb +2 -1
- data/lib/rubyzen/rspec.rb +29 -0
- data/lib/rubyzen/version.rb +2 -1
- data/lib/rubyzen.rb +8 -95
- data/rubyzen-lint.gemspec +9 -4
- metadata +49 -5
- data/lib/rubyzen/matchers/matcher_helpers.rb +0 -176
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7ce170df4572f8af2962998ff57160f0027b39288a5f53d566234ff5e1442b43
|
|
4
|
+
data.tar.gz: 1189a743bb4de63887b7f45d4387a3b3fb6571ef5cb4a67763bbc0f69c31bde8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c424e1404f091fa5c965a4253f15b853e031b9078b305df0fa770d94fa545524f5f17776100bc7986ff630cb10c9506338886604d3a0c6b11f77058b66725a9b
|
|
7
|
+
data.tar.gz: 4275ca620f94b150758f12222064d2ec5b0314502a4a3da4a8bed40d1d937fc580f8f8d23878dfd74933a59615843c6bc5b35c0ff106fb525c816d2c1419c224
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.0]
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Minitest adapter.** Write architectural lint rules with Minitest using
|
|
8
|
+
`require 'rubyzen/minitest'`. This provides the `assert_zen_empty`, `assert_zen_true`,
|
|
9
|
+
and `assert_zen_false` assertions, which mirror the RSpec matchers.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **`rspec` is no longer a runtime dependency.** Rubyzen no longer depends
|
|
14
|
+
on RSpec at runtime; RSpec users should add `gem 'rspec'` (or `rspec-rails`)
|
|
15
|
+
to their Gemfile. This allows Minitest users to not depend on RSpec.
|
|
16
|
+
|
|
17
|
+
### Migration from 0.1.x
|
|
18
|
+
|
|
19
|
+
- Change `require 'rubyzen'` to `require 'rubyzen/rspec'`.
|
|
20
|
+
- Ensure `gem 'rspec'` is in your Gemfile's test group.
|
|
21
|
+
|
|
22
|
+
## [0.1.0]
|
|
23
|
+
|
|
24
|
+
- Initial release
|
data/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# Rubyzen
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://badge.fury.io/rb/rubyzen-lint)
|
|
4
|
+
[](https://github.com/perrystreetsoftware/Rubyzen/actions/workflows/tests.yml)
|
|
5
|
+
[](https://perrystreetsoftware.github.io/Rubyzen)
|
|
6
|
+
|
|
7
|
+
Rubyzen is an architectural linter for Ruby that allows you to write architectural lint rules as unit tests, inspired by [Konsist](https://github.com/LemonAppDev/konsist) (for Kotlin) and [Harmonize](https://github.com/perrystreetsoftware/Harmonize) (for Swift).
|
|
4
8
|
|
|
5
9
|
## Architectural linters in the era of AI-generated code
|
|
6
10
|
|
|
@@ -24,22 +28,26 @@ Traditional linters such as [RuboCop](https://github.com/rubocop/rubocop) requir
|
|
|
24
28
|
|
|
25
29
|
## Setup
|
|
26
30
|
|
|
27
|
-
Add Rubyzen to your Gemfile
|
|
31
|
+
Add Rubyzen to your Gemfile's test group, alongside your test framework.
|
|
28
32
|
|
|
29
33
|
```ruby
|
|
30
|
-
|
|
34
|
+
group :test do
|
|
35
|
+
gem 'rubyzen-lint'
|
|
36
|
+
# gem 'rspec' # if you use RSpec (or rspec-rails)
|
|
37
|
+
# gem 'minitest' # if you use Minitest
|
|
38
|
+
end
|
|
31
39
|
```
|
|
32
40
|
|
|
33
41
|
Then run `bundle install`.
|
|
34
42
|
|
|
35
|
-
|
|
43
|
+
Rubyzen auto-discovers your project structure (`app/`, `lib/`, `src/`, `spec/`) from your project root. If you need to lint other directories (e.g., `config/`, `db/`), see [Custom Paths](#custom-paths) below.
|
|
36
44
|
|
|
37
45
|
## Write your first set of lint rules
|
|
38
46
|
|
|
39
47
|
Create a spec file anywhere in your project (e.g., `spec/architecture/sample_spec.rb`) and start enforcing your architecture:
|
|
40
48
|
|
|
41
49
|
```ruby
|
|
42
|
-
require 'rubyzen'
|
|
50
|
+
require 'rubyzen/rspec'
|
|
43
51
|
|
|
44
52
|
RSpec.describe 'Architecture rules' do
|
|
45
53
|
let(:project) { Rubyzen::Project.new }
|
|
@@ -64,15 +72,49 @@ end
|
|
|
64
72
|
|
|
65
73
|
You can find more sample lint rules in the [`sample_project/spec/`](sample_project/spec/) directory.
|
|
66
74
|
|
|
75
|
+
## Using Minitest
|
|
76
|
+
|
|
77
|
+
If you use Minitest instead of RSpec, replace `require 'rubyzen/rspec'` with `require 'rubyzen/minitest'` and use the equivalent Minitest assertions:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
require 'rubyzen/minitest'
|
|
81
|
+
|
|
82
|
+
class ArchitectureTest < Minitest::Test
|
|
83
|
+
def controllers = Rubyzen::Project.new.files.with_paths('app/controllers/').classes
|
|
84
|
+
|
|
85
|
+
def test_controllers_do_not_call_active_record_directly
|
|
86
|
+
assert_zen_empty(controllers.all_methods.call_sites.with_name('where'))
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Matchers and assertions
|
|
92
|
+
|
|
93
|
+
Rubyzen provides three checks for your architectural lint rules:
|
|
94
|
+
|
|
95
|
+
| Using RSpec | Using Minitest | Checks that |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `zen_empty` | `assert_zen_empty(collection)` | the collection is empty |
|
|
98
|
+
| `zen_true { \|item\| }` | `assert_zen_true(collection) { \|item\| }` | the block returns true for every element |
|
|
99
|
+
| `zen_false { \|item\| }` | `assert_zen_false(collection) { \|item\| }` | the block returns false for every element |
|
|
100
|
+
|
|
101
|
+
All three accept an `allowlist:` of exceptions that are permanently exempt from the rule, a `baseline:` of known existing violations (technical debt) to fix over time, and a custom failure message.
|
|
102
|
+
|
|
67
103
|
## Run your lint rules
|
|
68
104
|
|
|
69
105
|
```bash
|
|
106
|
+
# If you use RSpec:
|
|
70
107
|
bundle exec rspec spec/architecture/
|
|
108
|
+
|
|
109
|
+
# If you use Minitest in Rails:
|
|
110
|
+
bin/rails test test/architecture
|
|
71
111
|
```
|
|
72
112
|
|
|
73
|
-
|
|
113
|
+
If you use Minitest outside of Rails, you can use a `Rake::TestTask` to run all lint rules in `test/architecture/`.
|
|
114
|
+
|
|
115
|
+
## Custom Paths
|
|
74
116
|
|
|
75
|
-
By default, `Rubyzen::Project.new` scans
|
|
117
|
+
By default, `Rubyzen::Project.new` scans `app/`, `lib/`, `src/`, and `spec/`. If you need to lint other directories (e.g., `config/`, `db/`), add them explicitly, otherwise those files won't be scanned and queries against them will return empty results.
|
|
76
118
|
|
|
77
119
|
```ruby
|
|
78
120
|
# In your spec file — scope to specific directories
|
|
@@ -80,7 +122,7 @@ project = Rubyzen::Project.new(['app/models', 'app/controllers'])
|
|
|
80
122
|
|
|
81
123
|
# Or in spec/spec_helper.rb — configure globally for all specs
|
|
82
124
|
Rubyzen.configure do |config|
|
|
83
|
-
config.paths = ['app', 'lib']
|
|
125
|
+
config.paths = ['app', 'lib', 'config']
|
|
84
126
|
end
|
|
85
127
|
```
|
|
86
128
|
|
|
@@ -93,6 +135,8 @@ Add a step to your existing CI workflow to run your lint rules automatically whe
|
|
|
93
135
|
run: bundle exec rspec spec/architecture/
|
|
94
136
|
```
|
|
95
137
|
|
|
138
|
+
If you use Minitest in Rails, run `bin/rails test test/architecture` instead. For plain Ruby apps, use the `Rake::TestTask` described above.
|
|
139
|
+
|
|
96
140
|
## AI Agent Skills
|
|
97
141
|
|
|
98
142
|
Rubyzen includes agent skills in `.claude/skills/` (also symlinked at `.github/skills/`) that work with both Claude Code and GitHub Copilot:
|
|
@@ -107,4 +151,8 @@ Rubyzen includes agent skills in `.claude/skills/` (also symlinked at `.github/s
|
|
|
107
151
|
|
|
108
152
|
## Contributing
|
|
109
153
|
|
|
110
|
-
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for instructions on setting up the project, enhancing the API, and adding tests.
|
|
154
|
+
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for instructions on setting up the project, enhancing the API, and adding tests.
|
|
155
|
+
|
|
156
|
+
## Perry Street Software is hiring
|
|
157
|
+
|
|
158
|
+
If you are a Ruby or DevOps engineer excited about [application architecture](https://itnext.io/a-visual-history-of-web-api-architecture-c36044df2ac7) on a platform running at very large scale, check out our [careers](https://www.perrystreet.com/careers) page! Our company has written extensively about our technical approach on the [Perry Street Software Engineering Blog](https://medium.com/perry-street-software-engineering).
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Rubyzen
|
|
2
|
+
module Assertions
|
|
3
|
+
# Asserts that a Rubyzen collection is empty (the Minitest counterpart of
|
|
4
|
+
# the +zen_empty+ matcher).
|
|
5
|
+
#
|
|
6
|
+
# Used in architectural lint rules to verify that no items match a forbidden
|
|
7
|
+
# pattern (e.g., no controllers call +.where+ directly).
|
|
8
|
+
#
|
|
9
|
+
# @param collection [Enumerable] a Rubyzen collection of declarations
|
|
10
|
+
# @param message [String, nil] optional custom failure message
|
|
11
|
+
# @param allowlist [Array<String>, nil] items to permanently ignore
|
|
12
|
+
# @param baseline [Array<String>, nil] known violations for gradual adoption
|
|
13
|
+
# @return [true] when the assertion passes
|
|
14
|
+
# @raise [Minitest::Assertion] when there are live violations or stale entries
|
|
15
|
+
#
|
|
16
|
+
# @example Ensure no controllers use .where
|
|
17
|
+
# assert_zen_empty(controllers.all_methods.call_sites.with_name('where'))
|
|
18
|
+
#
|
|
19
|
+
# @example With a baseline for gradual adoption
|
|
20
|
+
# assert_zen_empty(violations, baseline: ['LegacyController'])
|
|
21
|
+
def assert_zen_empty(collection, message: nil, allowlist: nil, baseline: nil)
|
|
22
|
+
@failure_message = nil
|
|
23
|
+
@custom_message = message
|
|
24
|
+
@classified_items = classify_items(collection, allowlist: allowlist, baseline: baseline)
|
|
25
|
+
|
|
26
|
+
violations = @classified_items[:violations]
|
|
27
|
+
stale_baseline = @classified_items[:stale_baseline]
|
|
28
|
+
stale_allowlist = @classified_items[:stale_allowlist]
|
|
29
|
+
|
|
30
|
+
stale_groups = []
|
|
31
|
+
stale_groups << 'baseline entries' if stale_baseline.any?
|
|
32
|
+
stale_groups << 'allowlist entries' if stale_allowlist.any?
|
|
33
|
+
|
|
34
|
+
reason =
|
|
35
|
+
if violations.any? && stale_groups.any?
|
|
36
|
+
"Expected to be empty, but found live violations and stale #{stale_groups.join(' and ')}."
|
|
37
|
+
elsif violations.any?
|
|
38
|
+
if allowlist || baseline
|
|
39
|
+
'Expected to be empty, but found live violations.'
|
|
40
|
+
else
|
|
41
|
+
'Expected to be empty, but had elements.'
|
|
42
|
+
end
|
|
43
|
+
elsif stale_groups.any?
|
|
44
|
+
"Expected to be empty, but found stale #{stale_groups.join(' and ')}."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
passed = violations.empty? && stale_baseline.empty? && stale_allowlist.empty?
|
|
48
|
+
assert(passed, message_for_failure(reason || 'Expected to be empty, but had elements.'))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Rubyzen
|
|
2
|
+
module Assertions
|
|
3
|
+
# Asserts that a block returns false for every item in a collection (the
|
|
4
|
+
# Minitest counterpart of the +zen_false+ matcher).
|
|
5
|
+
#
|
|
6
|
+
# @param collection [Enumerable] a Rubyzen collection of declarations
|
|
7
|
+
# @param message [String, nil] optional custom failure message
|
|
8
|
+
# @param allowlist [Array<String>, nil] items to permanently ignore
|
|
9
|
+
# @param baseline [Array<String>, nil] known violations for gradual adoption
|
|
10
|
+
# @yield [item] block that should return false for each item
|
|
11
|
+
# @return [true] when the assertion passes
|
|
12
|
+
# @raise [ArgumentError] when no block is given
|
|
13
|
+
# @raise [Minitest::Assertion] when the block returns truthy for a live item
|
|
14
|
+
#
|
|
15
|
+
# @example Ensure no methods have more than 5 parameters
|
|
16
|
+
# assert_zen_false(methods) { |m| m.parameters.size > 5 }
|
|
17
|
+
#
|
|
18
|
+
# @example With a baseline for gradual adoption
|
|
19
|
+
# assert_zen_false(classes, baseline: ['LegacyModel']) { |k| k.lines_of_code > 200 }
|
|
20
|
+
def assert_zen_false(collection, message: nil, allowlist: nil, baseline: nil, &block)
|
|
21
|
+
raise ArgumentError, 'assert_zen_false requires a block' unless block
|
|
22
|
+
|
|
23
|
+
@failure_message = nil
|
|
24
|
+
@custom_message = message
|
|
25
|
+
failing_items = Array(collection).filter { |item| block.call(item) }
|
|
26
|
+
@classified_items = classify_items(failing_items, allowlist: allowlist, baseline: baseline)
|
|
27
|
+
|
|
28
|
+
violations = @classified_items[:violations]
|
|
29
|
+
stale_baseline = @classified_items[:stale_baseline]
|
|
30
|
+
stale_allowlist = @classified_items[:stale_allowlist]
|
|
31
|
+
|
|
32
|
+
stale_groups = []
|
|
33
|
+
stale_groups << 'baseline entries' if stale_baseline.any?
|
|
34
|
+
stale_groups << 'allowlist entries' if stale_allowlist.any?
|
|
35
|
+
|
|
36
|
+
reason =
|
|
37
|
+
if violations.any? && stale_groups.any?
|
|
38
|
+
"Expected to return false for all elements, but found live violations and stale #{stale_groups.join(' and ')}."
|
|
39
|
+
elsif violations.any?
|
|
40
|
+
'Expected to return false for all elements.'
|
|
41
|
+
elsif stale_groups.any?
|
|
42
|
+
"Expected to return false for all elements, but found stale #{stale_groups.join(' and ')}."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
passed = violations.empty? && stale_baseline.empty? && stale_allowlist.empty?
|
|
46
|
+
assert(passed, message_for_failure(reason || 'Expected to return false for all elements.'))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Rubyzen
|
|
2
|
+
module Assertions
|
|
3
|
+
# Asserts that a block returns true for every item in a collection (the
|
|
4
|
+
# Minitest counterpart of the +zen_true+ matcher).
|
|
5
|
+
#
|
|
6
|
+
# @param collection [Enumerable] a Rubyzen collection of declarations
|
|
7
|
+
# @param message [String, nil] optional custom failure message
|
|
8
|
+
# @param allowlist [Array<String>, nil] items to permanently ignore
|
|
9
|
+
# @param baseline [Array<String>, nil] known violations for gradual adoption
|
|
10
|
+
# @yield [item] block that should return true for each item
|
|
11
|
+
# @return [true] when the assertion passes
|
|
12
|
+
# @raise [ArgumentError] when no block is given
|
|
13
|
+
# @raise [Minitest::Assertion] when the block returns falsey for a live item
|
|
14
|
+
#
|
|
15
|
+
# @example Ensure all methods have parameters
|
|
16
|
+
# assert_zen_true(methods) { |m| m.parameters? }
|
|
17
|
+
#
|
|
18
|
+
# @example With a custom failure message
|
|
19
|
+
# assert_zen_true(services, message: 'All services must inherit from BaseService') { |s| s.superclass_name == 'BaseService' }
|
|
20
|
+
def assert_zen_true(collection, message: nil, allowlist: nil, baseline: nil, &block)
|
|
21
|
+
raise ArgumentError, 'assert_zen_true requires a block' unless block
|
|
22
|
+
|
|
23
|
+
@failure_message = nil
|
|
24
|
+
@custom_message = message
|
|
25
|
+
failing_items = Array(collection).filter { |item| !block.call(item) }
|
|
26
|
+
@classified_items = classify_items(failing_items, allowlist: allowlist, baseline: baseline)
|
|
27
|
+
|
|
28
|
+
violations = @classified_items[:violations]
|
|
29
|
+
stale_baseline = @classified_items[:stale_baseline]
|
|
30
|
+
stale_allowlist = @classified_items[:stale_allowlist]
|
|
31
|
+
|
|
32
|
+
stale_groups = []
|
|
33
|
+
stale_groups << 'baseline entries' if stale_baseline.any?
|
|
34
|
+
stale_groups << 'allowlist entries' if stale_allowlist.any?
|
|
35
|
+
|
|
36
|
+
reason =
|
|
37
|
+
if violations.any? && stale_groups.any?
|
|
38
|
+
"Expected to return true for all elements, but found live violations and stale #{stale_groups.join(' and ')}."
|
|
39
|
+
elsif violations.any?
|
|
40
|
+
'Expected to return true for all elements.'
|
|
41
|
+
elsif stale_groups.any?
|
|
42
|
+
"Expected to return true for all elements, but found stale #{stale_groups.join(' and ')}."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
passed = violations.empty? && stale_baseline.empty? && stale_allowlist.empty?
|
|
46
|
+
assert(passed, message_for_failure(reason || 'Expected to return true for all elements.'))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require_relative '../expectation_helpers'
|
|
2
|
+
|
|
3
|
+
module Rubyzen
|
|
4
|
+
# Minitest equivalents of the +zen_empty+ / +zen_true+ / +zen_false+ RSpec
|
|
5
|
+
# matchers. Mixed into +Minitest::Assertions+ by +require 'rubyzen/minitest'+,
|
|
6
|
+
# so the methods are available in every +Minitest::Test+ (and spec-style block).
|
|
7
|
+
#
|
|
8
|
+
# All three delegate to the shared {Rubyzen::ExpectationHelpers} for
|
|
9
|
+
# violation/allowlist/baseline classification and failure-message formatting.
|
|
10
|
+
# The behavior is identical to the RSpec matchers ({Rubyzen::Matchers}).
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# class ArchitectureTest < Minitest::Test
|
|
14
|
+
# def test_controllers_have_no_if_statements
|
|
15
|
+
# assert_zen_empty(controllers.all_methods.if_statements)
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# def test_repos_live_in_module
|
|
19
|
+
# assert_zen_true(repos) { |repo| repo.top_level_module == 'Repos' }
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
module Assertions
|
|
23
|
+
include Rubyzen::ExpectationHelpers
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
require_relative 'assert_zen_empty'
|
|
28
|
+
require_relative 'assert_zen_true'
|
|
29
|
+
require_relative 'assert_zen_false'
|
data/lib/rubyzen/core.rb
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
require 'rubocop-ast'
|
|
2
|
+
require 'zeitwerk'
|
|
3
|
+
|
|
4
|
+
# Framework-agnostic core of Rubyzen.
|
|
5
|
+
#
|
|
6
|
+
# Owns the Zeitwerk autoloading of the parsing API (declarations, collections,
|
|
7
|
+
# providers, parsers) and defines the +Rubyzen+ module and its +Configuration+.
|
|
8
|
+
#
|
|
9
|
+
# This file deliberately does NOT require any test framework (RSpec or Minitest)
|
|
10
|
+
# so that adapters can load only what they need:
|
|
11
|
+
# * +require 'rubyzen'+ → core only (no test framework)
|
|
12
|
+
# * +require 'rubyzen/rspec'+ → core + RSpec matchers
|
|
13
|
+
# * +require 'rubyzen/minitest'+ → core + Minitest assertions
|
|
14
|
+
lib_dir = File.expand_path('..', __dir__)
|
|
15
|
+
|
|
16
|
+
loader = Zeitwerk::Loader.new
|
|
17
|
+
loader.tag = 'rubyzen'
|
|
18
|
+
loader.push_dir(lib_dir)
|
|
19
|
+
# Entry points and adapter directories are loaded explicitly, not autoloaded.
|
|
20
|
+
loader.ignore("#{lib_dir}/rubyzen.rb")
|
|
21
|
+
loader.ignore("#{lib_dir}/rubyzen-lint.rb")
|
|
22
|
+
loader.ignore("#{__dir__}/core.rb")
|
|
23
|
+
loader.ignore("#{__dir__}/minitest.rb")
|
|
24
|
+
loader.ignore("#{__dir__}/lint.rb")
|
|
25
|
+
loader.ignore("#{__dir__}/matchers")
|
|
26
|
+
loader.ignore("#{__dir__}/assertions")
|
|
27
|
+
loader.ignore("#{__dir__}/expectation_helpers.rb")
|
|
28
|
+
loader.ignore("#{__dir__}/rspec.rb")
|
|
29
|
+
loader.setup
|
|
30
|
+
|
|
31
|
+
# Rubyzen is a Ruby architectural linter that lets you write lint rules as unit tests.
|
|
32
|
+
# It wraps RuboCop AST to provide a high-level, easy-to-use API for enforcing architectural
|
|
33
|
+
# rules across a codebase.
|
|
34
|
+
#
|
|
35
|
+
# `require 'rubyzen'` loads this framework-agnostic core API only. To make an assertion on a
|
|
36
|
+
# collection in a test, require the respective adapter: +rubyzen/rspec+ (the +zen_*+ matchers) or
|
|
37
|
+
# +rubyzen/minitest+ (the +assert_zen_*+ assertions).
|
|
38
|
+
#
|
|
39
|
+
# @example Querying the project
|
|
40
|
+
# project = Rubyzen::Project.new(["/path/to/src", "/path/to/spec"])
|
|
41
|
+
# controllers = project.files.with_paths("controllers/").classes
|
|
42
|
+
# controllers.all_methods.call_sites.with_name("where") # => CallSiteCollection
|
|
43
|
+
#
|
|
44
|
+
# @example Using auto-discovery (from project root)
|
|
45
|
+
# project = Rubyzen::Project.new # scans app/, lib/, src/, spec/ automatically
|
|
46
|
+
#
|
|
47
|
+
module Rubyzen
|
|
48
|
+
# Base error class for all Rubyzen errors.
|
|
49
|
+
class Error < StandardError; end
|
|
50
|
+
|
|
51
|
+
# Raised when a Ruby file cannot be parsed.
|
|
52
|
+
class ParseError < Error; end
|
|
53
|
+
|
|
54
|
+
# Yields the global configuration for customization.
|
|
55
|
+
#
|
|
56
|
+
# @example
|
|
57
|
+
# Rubyzen.configure do |config|
|
|
58
|
+
# config.paths = ['app', 'lib']
|
|
59
|
+
# end
|
|
60
|
+
def self.configure
|
|
61
|
+
yield(configuration)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns the global configuration instance.
|
|
65
|
+
#
|
|
66
|
+
# @return [Configuration]
|
|
67
|
+
def self.configuration
|
|
68
|
+
@configuration ||= Configuration.new
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Holds project path configuration with auto-discovery support.
|
|
72
|
+
#
|
|
73
|
+
# Resolution order:
|
|
74
|
+
# 1. Explicit paths via {#paths=} (set via +Rubyzen.configure+)
|
|
75
|
+
# 2. Auto-discovery of +app/+, +lib/+, +src/+, +spec/+ from +Dir.pwd+
|
|
76
|
+
#
|
|
77
|
+
# @example
|
|
78
|
+
# Rubyzen.configure { |c| c.paths = ['app/models', 'app/controllers'] }
|
|
79
|
+
# Rubyzen.configuration.project_paths #=> ["/full/path/app/models", "/full/path/app/controllers"]
|
|
80
|
+
#
|
|
81
|
+
class Configuration
|
|
82
|
+
# Sets explicit paths to scan.
|
|
83
|
+
# Relative paths are resolved against +Dir.pwd+.
|
|
84
|
+
#
|
|
85
|
+
# @param value [Array<String>] directories to analyze
|
|
86
|
+
attr_writer :paths
|
|
87
|
+
|
|
88
|
+
# Returns the resolved project paths.
|
|
89
|
+
#
|
|
90
|
+
# @return [Array<String>] absolute paths to directories to analyze
|
|
91
|
+
def project_paths
|
|
92
|
+
resolve_paths(@paths) || auto_discover_paths
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def resolve_paths(paths)
|
|
98
|
+
return nil unless paths&.any?
|
|
99
|
+
|
|
100
|
+
root = Dir.pwd
|
|
101
|
+
paths.map do |path|
|
|
102
|
+
File.expand_path(path, root)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def auto_discover_paths
|
|
107
|
+
root = Dir.pwd
|
|
108
|
+
candidates = %w[app lib src spec].map { |d| File.join(root, d) }
|
|
109
|
+
paths = candidates.select { |d| Dir.exist?(d) }
|
|
110
|
+
paths = [root] if paths.empty?
|
|
111
|
+
paths
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
module Rubyzen
|
|
2
|
+
# Shared helper methods for Rubyzen's +zen_*+ / +assert_zen_*+ expectations —
|
|
3
|
+
# used by both the RSpec matchers ({Rubyzen::Matchers}) and the Minitest
|
|
4
|
+
# assertions ({Rubyzen::Assertions}).
|
|
5
|
+
#
|
|
6
|
+
# Provides utilities for normalizing exception lists, extracting item details,
|
|
7
|
+
# matching items against allowlist/baseline entries, and formatting failure
|
|
8
|
+
# messages. These methods are framework-agnostic — they operate only on plain
|
|
9
|
+
# declaration objects and instance variables set by the caller.
|
|
10
|
+
module ExpectationHelpers
|
|
11
|
+
# Normalizes a list of exception entries into unique, non-blank strings.
|
|
12
|
+
#
|
|
13
|
+
# @param entries [Array<String>, String, nil] raw exception entries
|
|
14
|
+
# @return [Array<String>] deduplicated, stripped, non-empty strings
|
|
15
|
+
def normalize_exception_entries(entries)
|
|
16
|
+
Array(entries).flatten.compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Extracts identifying details from a declaration item.
|
|
20
|
+
#
|
|
21
|
+
# @param item [Object] a declaration object (e.g., FileDeclaration, ClassDeclaration)
|
|
22
|
+
# @return [Hash{Symbol => String, nil}] hash with :name, :class_name, :file_path, :line
|
|
23
|
+
def item_details(item)
|
|
24
|
+
{
|
|
25
|
+
name: item.respond_to?(:name) ? item.name : nil,
|
|
26
|
+
class_name: item.respond_to?(:class_name) ? item.class_name : nil,
|
|
27
|
+
file_path: item.respond_to?(:file_path) ? item.file_path : 'Unknown file',
|
|
28
|
+
line: item.respond_to?(:line) ? item.line : nil
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns a list of unique identifier strings for an item, used for matching.
|
|
33
|
+
#
|
|
34
|
+
# @param item [Object] a declaration object
|
|
35
|
+
# @return [Array<String>] identifiers such as name, class name, file path, and file:line
|
|
36
|
+
def item_identifiers(item)
|
|
37
|
+
details = item_details(item)
|
|
38
|
+
identifiers = [details[:name], details[:class_name], details[:file_path]]
|
|
39
|
+
|
|
40
|
+
if details[:line]
|
|
41
|
+
identifiers << "#{details[:file_path]}:#{details[:line]}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
identifiers.compact.uniq
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Checks whether a given exception entry string matches an item.
|
|
48
|
+
#
|
|
49
|
+
# @param entry [String] an allowlist or baseline entry
|
|
50
|
+
# @param item [Object] a declaration object
|
|
51
|
+
# @return [Boolean] true if the entry matches the item by name, class, or path
|
|
52
|
+
def exception_entry_matches_item?(entry, item)
|
|
53
|
+
normalized_entry = entry.to_s.strip
|
|
54
|
+
return false if normalized_entry.empty?
|
|
55
|
+
|
|
56
|
+
details = item_details(item)
|
|
57
|
+
return true if item_identifiers(item).include?(normalized_entry)
|
|
58
|
+
|
|
59
|
+
file_path = details[:file_path]
|
|
60
|
+
file_path && (file_path.end_with?(normalized_entry) || file_path.end_with?("/#{normalized_entry}"))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Classifies items into violations, baseline matches, allowlist matches,
|
|
64
|
+
# and detects stale entries in either list.
|
|
65
|
+
#
|
|
66
|
+
# @param subject_collection [Array, Object] items to classify
|
|
67
|
+
# @param allowlist [Array<String>, nil] allowed exception entries
|
|
68
|
+
# @param baseline [Array<String>, nil] baseline exception entries
|
|
69
|
+
# @return [Hash{Symbol => Array<String>}] keys: :violations, :baseline, :allowlist,
|
|
70
|
+
# :stale_baseline, :stale_allowlist
|
|
71
|
+
def classify_items(subject_collection, allowlist: nil, baseline: nil)
|
|
72
|
+
items = Array(subject_collection).compact
|
|
73
|
+
normalized_allowlist = normalize_exception_entries(allowlist)
|
|
74
|
+
normalized_baseline = normalize_exception_entries(baseline)
|
|
75
|
+
matched_baseline_entries = []
|
|
76
|
+
matched_allowlist_entries = []
|
|
77
|
+
|
|
78
|
+
grouped_items = items.group_by do |item|
|
|
79
|
+
matching_baseline_entry = normalized_baseline.find do |entry|
|
|
80
|
+
exception_entry_matches_item?(entry, item)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if matching_baseline_entry
|
|
84
|
+
matched_baseline_entries << matching_baseline_entry
|
|
85
|
+
:baseline
|
|
86
|
+
else
|
|
87
|
+
matching_allowlist_entry = normalized_allowlist.find do |entry|
|
|
88
|
+
exception_entry_matches_item?(entry, item)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if matching_allowlist_entry
|
|
92
|
+
matched_allowlist_entries << matching_allowlist_entry
|
|
93
|
+
:allowlist
|
|
94
|
+
else
|
|
95
|
+
:violations
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
classifications = {
|
|
101
|
+
baseline: Array(grouped_items[:baseline]).map { |item| element_name(item) },
|
|
102
|
+
allowlist: Array(grouped_items[:allowlist]).map { |item| element_name(item) },
|
|
103
|
+
violations: Array(grouped_items[:violations]).map { |item| element_name(item) }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
classifications.merge(
|
|
107
|
+
stale_baseline: normalized_baseline - matched_baseline_entries.uniq,
|
|
108
|
+
stale_allowlist: normalized_allowlist - matched_allowlist_entries.uniq
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Formats a human-readable description of an item for failure messages.
|
|
113
|
+
#
|
|
114
|
+
# @param item [Object] a declaration object
|
|
115
|
+
# @return [String] formatted multi-line description
|
|
116
|
+
def element_name(item)
|
|
117
|
+
details = item_details(item)
|
|
118
|
+
location = [details[:file_path], details[:line]].compact.join(':')
|
|
119
|
+
|
|
120
|
+
case
|
|
121
|
+
when details[:name] && details[:class_name]
|
|
122
|
+
" - element: #{details[:name]}\n - class: #{details[:class_name]}\n - file: #{location}"
|
|
123
|
+
when details[:name]
|
|
124
|
+
" - element: #{details[:name]}\n - file: #{location}"
|
|
125
|
+
when details[:class_name]
|
|
126
|
+
" - class: #{details[:class_name]}\n - file: #{location}"
|
|
127
|
+
else
|
|
128
|
+
" - unknown element in #{location}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Builds a formatted string of violations and stale entries for failure output.
|
|
133
|
+
#
|
|
134
|
+
# @return [String, nil] formatted sections or nil if no classified items
|
|
135
|
+
def formatted_matcher_groups
|
|
136
|
+
return unless defined?(@classified_items) && @classified_items
|
|
137
|
+
|
|
138
|
+
sections = []
|
|
139
|
+
|
|
140
|
+
if @classified_items[:violations].any?
|
|
141
|
+
sections << "Violations:\n#{@classified_items[:violations].join("\n")}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
if @classified_items[:stale_baseline].any?
|
|
145
|
+
stale_entries = @classified_items[:stale_baseline].map { |entry| " - #{entry}" }
|
|
146
|
+
sections << "Stale baseline entries:\n#{stale_entries.join("\n")}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
if @classified_items[:stale_allowlist].any?
|
|
150
|
+
stale_entries = @classified_items[:stale_allowlist].map { |entry| " - #{entry}" }
|
|
151
|
+
sections << "Stale allowlist entries:\n#{stale_entries.join("\n")}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
sections.join("\n")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Formats the failure message by combining the base message with
|
|
158
|
+
# custom messages and classified item details (violations, stale entries).
|
|
159
|
+
#
|
|
160
|
+
# Works unchanged in both RSpec matchers and Minitest assertions, since it
|
|
161
|
+
# only reads instance variables set by the caller (+@failure_message+,
|
|
162
|
+
# +@custom_message+, +@classified_items+).
|
|
163
|
+
#
|
|
164
|
+
# @param base_message [String] the default failure message
|
|
165
|
+
# @return [String] formatted failure message
|
|
166
|
+
def message_for_failure(base_message)
|
|
167
|
+
return @failure_message if @failure_message
|
|
168
|
+
|
|
169
|
+
details = formatted_matcher_groups
|
|
170
|
+
|
|
171
|
+
if @custom_message
|
|
172
|
+
if details && !details.empty?
|
|
173
|
+
"#{@custom_message}\n#{details}"
|
|
174
|
+
else
|
|
175
|
+
@custom_message
|
|
176
|
+
end
|
|
177
|
+
elsif details && !details.empty?
|
|
178
|
+
"#{base_message}\n#{details}"
|
|
179
|
+
else
|
|
180
|
+
base_message
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|