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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/README.md +57 -9
  4. data/lib/rubyzen/assertions/assert_zen_empty.rb +51 -0
  5. data/lib/rubyzen/assertions/assert_zen_false.rb +49 -0
  6. data/lib/rubyzen/assertions/assert_zen_true.rb +49 -0
  7. data/lib/rubyzen/assertions/zen_assertions.rb +29 -0
  8. data/lib/rubyzen/cache/parse_cache.rb +1 -0
  9. data/lib/rubyzen/collections/arguments_collection.rb +16 -0
  10. data/lib/rubyzen/collections/assignments_collection.rb +11 -0
  11. data/lib/rubyzen/collections/base_collection.rb +1 -0
  12. data/lib/rubyzen/collections/blocks_collection.rb +21 -0
  13. data/lib/rubyzen/collections/expressions_collection.rb +27 -0
  14. data/lib/rubyzen/collections/methods_collection.rb +29 -0
  15. data/lib/rubyzen/collections/returns_collection.rb +20 -0
  16. data/lib/rubyzen/core.rb +114 -0
  17. data/lib/rubyzen/declarations/assignment_declaration.rb +57 -0
  18. data/lib/rubyzen/declarations/block_declaration.rb +2 -0
  19. data/lib/rubyzen/declarations/call_site_declaration.rb +14 -0
  20. data/lib/rubyzen/declarations/expression_declaration.rb +99 -0
  21. data/lib/rubyzen/declarations/file_declaration.rb +1 -0
  22. data/lib/rubyzen/declarations/method_declaration.rb +2 -0
  23. data/lib/rubyzen/declarations/return_declaration.rb +51 -0
  24. data/lib/rubyzen/expectation_helpers.rb +184 -0
  25. data/lib/rubyzen/matchers/zen_empty_matcher.rb +22 -11
  26. data/lib/rubyzen/matchers/zen_false_matcher.rb +22 -14
  27. data/lib/rubyzen/matchers/zen_true_matcher.rb +19 -8
  28. data/lib/rubyzen/minitest.rb +33 -0
  29. data/lib/rubyzen/parsers/a_s_t_parser.rb +1 -0
  30. data/lib/rubyzen/providers/arguments_provider.rb +15 -0
  31. data/lib/rubyzen/providers/assignments_provider.rb +15 -0
  32. data/lib/rubyzen/providers/blocks_provider.rb +2 -1
  33. data/lib/rubyzen/providers/enclosing_blocks_provider.rb +16 -0
  34. data/lib/rubyzen/providers/returns_provider.rb +49 -0
  35. data/lib/rubyzen/rspec.rb +29 -0
  36. data/lib/rubyzen/version.rb +2 -1
  37. data/lib/rubyzen.rb +8 -95
  38. data/rubyzen-lint.gemspec +9 -4
  39. metadata +65 -10
  40. data/lib/rubyzen/matchers/matcher_helpers.rb +0 -176
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e0bc97dbd17202e4245c3c8c6b1d16f7fcdef64eab20ff96ab835113e21fbd9
4
- data.tar.gz: 4fb9b3a30754c283823a44e208bd4de9ca0a035db5733df418ef1e70bd98172d
3
+ metadata.gz: 500e791179afc539a3b93a9300c55661567131776188a7ae484e5a2cffa3e12d
4
+ data.tar.gz: 1793077a6d2cfaa933c8a607cab96a3f84afe97e58f1a1926eaf6d60d2febed6
5
5
  SHA512:
6
- metadata.gz: c23bf87e539bdda57d28a5b9f0b5fdbbee847a88ce93bf012c92a1c00eaf48fe1f5e2a99bea6a2e070749c2c74451821026f742a2e4e50e12c5395ed4295b870
7
- data.tar.gz: 189a609b17528f6d0898defda84762df30273f4607f4e97882d21d255b111d719818adb9148d452c4ff5b9d7c29dbcc5c68749f36d0e96c86740451d096dc778
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
- Rubyzen is an architectural linter for Ruby that lets you 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).
3
+ [![Gem Version](https://badge.fury.io/rb/rubyzen-lint.svg)](https://badge.fury.io/rb/rubyzen-lint)
4
+ [![CI](https://github.com/perrystreetsoftware/Rubyzen/actions/workflows/tests.yml/badge.svg)](https://github.com/perrystreetsoftware/Rubyzen/actions/workflows/tests.yml)
5
+ [![Docs](https://img.shields.io/badge/docs-yard-blue)](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
- gem 'rubyzen-lint', group: :test
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
- That's it. Rubyzen auto-discovers your project structure (`app/`, `lib/`, `src/`, `spec/`) from your project root. No environment variables or configuration needed.
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
- ## Custom Paths (Optional)
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 standard directories (`app/`, `lib/`, `src/`, `spec/`) from your project root. You can override this:
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'
@@ -1,6 +1,7 @@
1
1
  require 'digest'
2
2
 
3
3
  module Rubyzen
4
+ # Caching utilities for parsed AST results.
4
5
  module Cache
5
6
  # In-memory cache for parsed AST results, keyed by file path and SHA256 checksum.
6
7
  # Automatically invalidates entries when file contents change.
@@ -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
@@ -1,4 +1,5 @@
1
1
  module Rubyzen
2
+ # Typed collections that wrap arrays of declarations with filtering and aggregation.
2
3
  module Collections
3
4
  # Base collection class for all Rubyzen collections.
4
5
  # Extends Array and replaces +select+/+reject+ with a single +filter+ method
@@ -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
@@ -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