rubyzen-lint 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -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/arguments_collection.rb +16 -0
- data/lib/rubyzen/collections/assignments_collection.rb +11 -0
- data/lib/rubyzen/collections/base_collection.rb +1 -0
- data/lib/rubyzen/collections/blocks_collection.rb +21 -0
- data/lib/rubyzen/collections/expressions_collection.rb +27 -0
- data/lib/rubyzen/collections/methods_collection.rb +29 -0
- data/lib/rubyzen/collections/returns_collection.rb +20 -0
- data/lib/rubyzen/core.rb +114 -0
- data/lib/rubyzen/declarations/assignment_declaration.rb +57 -0
- data/lib/rubyzen/declarations/block_declaration.rb +2 -0
- data/lib/rubyzen/declarations/call_site_declaration.rb +14 -0
- data/lib/rubyzen/declarations/expression_declaration.rb +99 -0
- data/lib/rubyzen/declarations/file_declaration.rb +1 -0
- data/lib/rubyzen/declarations/method_declaration.rb +2 -0
- data/lib/rubyzen/declarations/return_declaration.rb +51 -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/arguments_provider.rb +15 -0
- data/lib/rubyzen/providers/assignments_provider.rb +15 -0
- data/lib/rubyzen/providers/blocks_provider.rb +2 -1
- data/lib/rubyzen/providers/enclosing_blocks_provider.rb +16 -0
- data/lib/rubyzen/providers/returns_provider.rb +49 -0
- 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 +65 -10
- 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: 500e791179afc539a3b93a9300c55661567131776188a7ae484e5a2cffa3e12d
|
|
4
|
+
data.tar.gz: 1793077a6d2cfaa933c8a607cab96a3f84afe97e58f1a1926eaf6d60d2febed6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fc076838beaafc1461c8c650e7a50e92ad0f4d32adc1c7f2ee15e9b47aeb9ac33a4742b2a98d47c23c2a73085f58b9cbbaa16d6a0d03526866472462bcb8f71f
|
|
7
|
+
data.tar.gz: b26b14d82f55b207b61601dd629154765488bf6154256738e11b1e96dfe14669109406513a12d4814c128e2cfbfc97b697c88ae68910d9d6c6c03a96c0d62164
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.3.0]
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`ExpressionDeclaration`** — a value-expression primitive wrapping any AST node, with
|
|
8
|
+
predicates (`constant?`, `local_variable?`, `method_call?`, `constructor?`, `hash_literal?`,
|
|
9
|
+
`symbol?`, `string?`) and accessors (`constant_name`, `method_name`, `name`).
|
|
10
|
+
- **`#returns`** on `MethodDeclaration` and `BlockDeclaration` — the points at which it yields a
|
|
11
|
+
value (the implicit final expression plus explicit `return`s) as `ReturnDeclaration`s
|
|
12
|
+
(`#explicit?`, `#implicit?`, `#expression`), collected in a `ReturnsCollection` (`#expressions`).
|
|
13
|
+
`#return_expressions` remains as a shortcut for `returns.expressions` — an `ExpressionsCollection`
|
|
14
|
+
(`#hash_literals`, `#constants`). Both are bridged on `MethodsCollection` and `BlocksCollection`.
|
|
15
|
+
- **`CallSiteDeclaration#arguments`** — the call's arguments as an `ArgumentsCollection`
|
|
16
|
+
(a subclass of `ExpressionsCollection`, so it keeps `#hash_literals`/`#constants`).
|
|
17
|
+
- **`CallSiteDeclaration#receiver_expression`** — the receiver modeled structurally (constant /
|
|
18
|
+
constructor / local variable). `#receiver` (String const-name) is unchanged.
|
|
19
|
+
- **`CallSiteDeclaration#enclosing_blocks`** — the chain of enclosing `do..end`/`{ }` blocks
|
|
20
|
+
(innermost first), as a `BlocksCollection`.
|
|
21
|
+
- **`#assignments`** on `MethodDeclaration` and `BlockDeclaration` — local-variable assignments
|
|
22
|
+
(`AssignmentDeclaration`, with `#name` and `#value`) as an `AssignmentsCollection`. Bridged on
|
|
23
|
+
`MethodsCollection` and `BlocksCollection`.
|
|
24
|
+
|
|
25
|
+
All additions are backward-compatible; no existing API changed.
|
|
26
|
+
|
|
27
|
+
## [0.2.0]
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **Minitest adapter.** Write architectural lint rules with Minitest using
|
|
32
|
+
`require 'rubyzen/minitest'`. This provides the `assert_zen_empty`, `assert_zen_true`,
|
|
33
|
+
and `assert_zen_false` assertions, which mirror the RSpec matchers.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- **`rspec` is no longer a runtime dependency.** Rubyzen no longer depends
|
|
38
|
+
on RSpec at runtime; RSpec users should add `gem 'rspec'` (or `rspec-rails`)
|
|
39
|
+
to their Gemfile. This allows Minitest users to not depend on RSpec.
|
|
40
|
+
|
|
41
|
+
### Migration from 0.1.x
|
|
42
|
+
|
|
43
|
+
- Change `require 'rubyzen'` to `require 'rubyzen/rspec'`.
|
|
44
|
+
- Ensure `gem 'rspec'` is in your Gemfile's test group.
|
|
45
|
+
|
|
46
|
+
## [0.1.0]
|
|
47
|
+
|
|
48
|
+
- 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'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Rubyzen
|
|
2
|
+
module Collections
|
|
3
|
+
# Collection of {Rubyzen::Declarations::ExpressionDeclaration} representing the arguments
|
|
4
|
+
# passed at a call site.
|
|
5
|
+
#
|
|
6
|
+
# A specialization of {ExpressionsCollection}: arguments are value-expressions, so this
|
|
7
|
+
# inherits the value-expression filters (`#hash_literals`, `#constants`) and remains a
|
|
8
|
+
# drop-in `ExpressionsCollection`. It exists as a distinct type so argument-specific
|
|
9
|
+
# filters (e.g. positional vs keyword) can be added later without a breaking change.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# call_site.arguments.first.constant_name
|
|
13
|
+
class ArgumentsCollection < ExpressionsCollection
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module Rubyzen
|
|
2
|
+
module Collections
|
|
3
|
+
# Collection of {Rubyzen::Declarations::AssignmentDeclaration} (local-variable assignments).
|
|
4
|
+
#
|
|
5
|
+
# @example
|
|
6
|
+
# method.assignments.with_name('user')
|
|
7
|
+
class AssignmentsCollection < BaseCollection
|
|
8
|
+
include Rubyzen::Providers::CollectionFilterProvider
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -22,6 +22,27 @@ module Rubyzen
|
|
|
22
22
|
all_call_sites = flat_map(&:call_sites)
|
|
23
23
|
CallSiteCollection.new(all_call_sites)
|
|
24
24
|
end
|
|
25
|
+
|
|
26
|
+
# Returns all return points across every block.
|
|
27
|
+
#
|
|
28
|
+
# @return [ReturnsCollection]
|
|
29
|
+
def returns
|
|
30
|
+
ReturnsCollection.new(flat_map(&:returns))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns all return expressions across every block.
|
|
34
|
+
#
|
|
35
|
+
# @return [ExpressionsCollection]
|
|
36
|
+
def return_expressions
|
|
37
|
+
returns.expressions
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns all local-variable assignments across every block.
|
|
41
|
+
#
|
|
42
|
+
# @return [AssignmentsCollection]
|
|
43
|
+
def assignments
|
|
44
|
+
AssignmentsCollection.new(flat_map(&:assignments))
|
|
45
|
+
end
|
|
25
46
|
end
|
|
26
47
|
end
|
|
27
48
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Rubyzen
|
|
2
|
+
module Collections
|
|
3
|
+
# Collection of {Rubyzen::Declarations::ExpressionDeclaration} — return values,
|
|
4
|
+
# call arguments, and other value-expressions.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# method.return_expressions.hash_literals
|
|
8
|
+
class ExpressionsCollection < BaseCollection
|
|
9
|
+
include Rubyzen::Providers::CollectionFilterProvider
|
|
10
|
+
|
|
11
|
+
# Filters to only braced Hash-literal expressions.
|
|
12
|
+
#
|
|
13
|
+
# @return [ExpressionsCollection]
|
|
14
|
+
def hash_literals
|
|
15
|
+
filter(&:hash_literal?)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Filters to only constant expressions, including constructors of a constant
|
|
19
|
+
# (e.g. both +Repos::Foo+ and +Repos::Foo.new+).
|
|
20
|
+
#
|
|
21
|
+
# @return [ExpressionsCollection]
|
|
22
|
+
def constants
|
|
23
|
+
filter(&:constant_name)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -62,6 +62,35 @@ module Rubyzen
|
|
|
62
62
|
end
|
|
63
63
|
)
|
|
64
64
|
end
|
|
65
|
+
|
|
66
|
+
# Returns all return points across every method.
|
|
67
|
+
#
|
|
68
|
+
# @return [ReturnsCollection]
|
|
69
|
+
def returns
|
|
70
|
+
ReturnsCollection.new(
|
|
71
|
+
flat_map do |method|
|
|
72
|
+
method.returns
|
|
73
|
+
end
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns all return expressions across every method.
|
|
78
|
+
#
|
|
79
|
+
# @return [ExpressionsCollection]
|
|
80
|
+
def return_expressions
|
|
81
|
+
returns.expressions
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Returns all local-variable assignments across every method.
|
|
85
|
+
#
|
|
86
|
+
# @return [AssignmentsCollection]
|
|
87
|
+
def assignments
|
|
88
|
+
AssignmentsCollection.new(
|
|
89
|
+
flat_map do |method|
|
|
90
|
+
method.assignments
|
|
91
|
+
end
|
|
92
|
+
)
|
|
93
|
+
end
|
|
65
94
|
end
|
|
66
95
|
end
|
|
67
96
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Rubyzen
|
|
2
|
+
module Collections
|
|
3
|
+
# Collection of {Rubyzen::Declarations::ReturnDeclaration} — the points at which a
|
|
4
|
+
# method or block yields a value.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# method.returns.expressions.hash_literals
|
|
8
|
+
class ReturnsCollection < BaseCollection
|
|
9
|
+
include Rubyzen::Providers::CollectionFilterProvider
|
|
10
|
+
|
|
11
|
+
# The value expressions of every return. Bare +return+s (which have no value)
|
|
12
|
+
# are omitted.
|
|
13
|
+
#
|
|
14
|
+
# @return [ExpressionsCollection]
|
|
15
|
+
def expressions
|
|
16
|
+
ExpressionsCollection.new(filter_map(&:expression))
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
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
|