diff --git a/.claude/skills/add-rubyzen-tests/SKILL.md b/.claude/skills/add-rubyzen-tests/SKILL.md new file mode 100644 index 0000000..e97ba60 --- /dev/null +++ b/.claude/skills/add-rubyzen-tests/SKILL.md @@ -0,0 +1,254 @@ +--- +name: add-rubyzen-tests +description: Write unit tests for Rubyzen's own API components — declarations, providers, collections, or matchers. Use this skill when the user wants to add tests for an existing or newly added Rubyzen component, increase test coverage, or write specs for untested methods. Also trigger when the user says "test this declaration", "add specs for", or "write tests for the X collection". +--- + +# Writing Unit Tests for Rubyzen + +You are writing unit tests for Rubyzen's internal API — the declarations, providers, collections, and matchers that make up the library. These tests verify that Rubyzen's own code works correctly. + +**This is NOT about writing lint rules.** Lint rules test user codebases; unit tests test Rubyzen itself. Use the `write-lint-rule` skill for lint rules. + +## Step 0: Understand the Test Infrastructure + +Read these files first: + +1. `spec/spec_helper.rb` — test configuration +2. `spec/support/parse_helper.rb` — the `parse_ruby` helper +3. An existing spec similar to what you're testing (e.g., `spec/declarations/class_declaration_spec.rb`) + +The `parse_ruby` helper is the foundation of all tests: + +```ruby +def parse_ruby(source, file_path: 'test.rb') + processed = RuboCop::AST::ProcessedSource.new(source, RUBY_VERSION.to_f, file_path) + Rubyzen::Declarations::FileDeclaration.new(file_path, processed.ast) +end +``` + +It takes an inline Ruby string and returns a `FileDeclaration`, bypassing file I/O. + +## Critical: The Single-Statement AST Gotcha + +When a Ruby snippet has **only one statement**, the AST root node IS that statement. Providers use `node.each_descendant` which only searches children — so the root node is invisible. + +```ruby +# BAD — single statement, root IS the :casgn node +file = parse_ruby('MAX = 100') +file.constants # => empty! each_descendant can't find the root + +# GOOD — two statements, root is :begin wrapper +file = parse_ruby("MAX = 100\nx = 1") +file.constants # => [ConstantDeclaration(MAX)] +``` + +**Always include at least two statements** in snippets that test file-level providers (`constants`, `requires`, `blocks`, `call_sites`). This does NOT affect class/method-level tests since those always have wrapper nodes. + +## Writing Declaration Specs + +File: `spec/declarations/_declaration_spec.rb` + +**Test every public method** on the declaration. Group related methods. + +```ruby +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::Declaration do + describe '#name' do + it 'returns the declaration name' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + # code containing the concept + end + end + RUBY + + decl = file.classes.first.instance_methods.first..first + expect(decl.name).to eq('expected') + end + end + + # Test every other public method in its own describe block... + + # Always test provider-inherited methods: + describe '#file_path' do + it 'returns the file path' do + file = parse_ruby(<<~RUBY, file_path: 'app/models/user.rb') + class User + def foo + # concept here + end + end + RUBY + + decl = file.classes.first.instance_methods.first..first + expect(decl.file_path).to eq('app/models/user.rb') + end + end + + describe '#line' do + it 'returns the line number' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + # concept on line 3 + end + end + RUBY + + decl = file.classes.first.instance_methods.first..first + expect(decl.line).to eq(3) + end + end + + describe '#class_name' do + it 'returns the enclosing class name' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + # concept here + end + end + RUBY + + decl = file.classes.first.instance_methods.first..first + expect(decl.class_name).to eq('Foo') + end + end +end +``` + +## Writing Collection Specs + +File: `spec/collections/_collection_spec.rb` + +Test three categories: + +1. **CollectionFilterProvider methods** (`with_name`, `without_name`, etc.) +2. **Domain-specific filter methods** (e.g., `with_receiver`, `with_exception_type`) +3. **Type preservation** — `filter` returns the same collection type + +```ruby +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::Collection do + # CollectionFilterProvider + describe '#with_name' do + it 'filters by exact name' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + # two concepts with different names + end + end + RUBY + + collection = file.classes.first.instance_methods.first. + result = collection.with_name('target') + expect(result.size).to eq(1) + expect(result.first.name).to eq('target') + end + end + + # Domain-specific filters + describe '#with_custom_filter' do + it 'filters by custom criteria' do + # ... + end + end + + # Type preservation + describe '#filter' do + it 'returns the same collection type' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + # concept here + end + end + RUBY + + collection = file.classes.first.instance_methods.first. + result = collection.filter { |d| d.name == 'something' } + expect(result).to be_a(described_class) + end + end + + # Bridge methods (if collection has them) + describe '#sub_collection' do + it 'aggregates sub-declarations into typed collection' do + # ... + end + end +end +``` + +## Writing Matcher Specs + +File: `spec/matchers/_matcher_spec.rb` + +Test pass and fail cases. Use `raise_error(RSpec::Expectations::ExpectationNotMetError)` to test failure: + +```ruby +require 'spec_helper' + +RSpec.describe 'be_empty matcher' do + it 'passes when collection is empty' do + file = parse_ruby(<<~RUBY) + class Foo + def bar; end + end + RUBY + + # Get an empty collection + collection = file.classes.first.instance_methods.first.call_sites + expect(collection).to be_empty + end + + it 'fails when collection is not empty' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + puts "hello" + end + end + RUBY + + collection = file.classes.first.instance_methods.first.call_sites + expect { + expect(collection).to be_empty + }.to raise_error(RSpec::Expectations::ExpectationNotMetError) + end +end +``` + +**Note:** Do NOT use `fail_with` — it's not available. Use `raise_error(RSpec::Expectations::ExpectationNotMetError, /pattern/)` to test failure messages. + +## When to Use Fixture Files + +Use `spec/fixtures/` with real `.rb` files only when testing: +- `Project.new(paths)` — needs real file paths +- `FileCollection#with_paths` / `#without_paths` — path matching +- `ParseCache` — file-based caching + +For everything else, use inline `parse_ruby` snippets. + +## Test Snippet Best Practices + +1. **Minimal snippets** — include only the Ruby code needed for the test +2. **Realistic names** — use realistic class/method names, not `Foo`/`bar` (when the name matters for the test) +3. **Multiple cases** — test edge cases (empty, nil, multiple items) +4. **Two+ statements** — for file-level concepts (see gotcha above) + +## Checklist + +- [ ] Spec file follows naming convention: `spec//_spec.rb` +- [ ] Requires `spec_helper` +- [ ] Tests every public method on the class +- [ ] Tests provider-inherited methods (`file_path`, `line`, `class_name`) +- [ ] Tests `CollectionFilterProvider` methods for collections +- [ ] Tests type preservation for collection `filter` +- [ ] Snippets have 2+ statements when testing file-level providers +- [ ] Does NOT use `fail_with` (uses `raise_error` instead) +- [ ] `bundle exec rspec spec/` passes diff --git a/.claude/skills/expand-rubyzen/SKILL.md b/.claude/skills/expand-rubyzen/SKILL.md new file mode 100644 index 0000000..e9e00c2 --- /dev/null +++ b/.claude/skills/expand-rubyzen/SKILL.md @@ -0,0 +1,311 @@ +--- +name: expand-rubyzen +description: Add a new code concept to Rubyzen — a new Declaration, Provider, and Collection as a full vertical slice. Use this skill whenever the user wants to add support for a new Ruby language construct (e.g., case statements, loops, yield calls, begin/end blocks, lambda expressions), add a new API to the library, or extend Rubyzen's analysis capabilities. Also use when the user says "add support for X" or "I want to lint on X" where X is a code structure not yet modeled. +--- + +# Expanding Rubyzen with a New Code Concept + +You are adding a new code concept to Rubyzen — a Ruby architectural linter that wraps RuboCop AST with a high-level API. Every new concept follows a **vertical slice**: Declaration → Provider → Collection → Tests → Wiring. + +## Step 0: Understand the Architecture First + +Before writing any code, read these files to understand current patterns: + +1. `CLAUDE.md` — full architecture guide +2. An existing declaration similar to what you're adding (e.g., `lib/rubyzen/declarations/call_site_declaration.rb`) +3. Its corresponding provider (e.g., `lib/rubyzen/providers/call_site_provider.rb`) +4. Its corresponding collection (e.g., `lib/rubyzen/collections/call_site_collection.rb`) +5. Its unit test (e.g., `spec/declarations/call_site_declaration_spec.rb`) + +This gives you the exact patterns to follow. Do not invent new patterns. + +## Step 1: Identify the AST Node Type + +Use RuboCop AST to determine which node type(s) represent the concept. You can test interactively: + +```ruby +source = "your ruby code here" +processed = RuboCop::AST::ProcessedSource.new(source, RUBY_VERSION.to_f) +processed.ast # inspect the AST +``` + +Common node types: `:send`, `:block`, `:if`, `:case`, `:while`, `:for`, `:yield`, `:return`, `:const`, `:casgn`, `:def`, `:defs`, `:class`, `:module`, `:resbody`, `:kwbegin`. + +## Step 2: Create the Declaration + +Create `lib/rubyzen/declarations/_declaration.rb`. + +**Mandatory structure:** + +```ruby +module Rubyzen + module Declarations + # YARD: Brief description of what this represents. + # + # @example + # decl = method..first + # decl.name #=> "example" + # + class Declaration + # Always include these three for matcher output: + include Rubyzen::Providers::FilePathProvider + include Rubyzen::Providers::LineNumberProvider + include Rubyzen::Providers::ClassNameProvider + + # Include additional providers based on what makes sense: + # include Rubyzen::Providers::SourceCodeProvider # if source_code is useful + # include Rubyzen::Providers::LinesOfCodeProvider # if lines_of_code is useful + # include Rubyzen::Providers::CallSiteProvider # if it can contain method calls + # include Rubyzen::Providers::BlocksProvider # if it can contain blocks + # include Rubyzen::Providers::RescuesProvider # if it can contain rescue clauses + # include Rubyzen::Providers::RaisesProvider # if it can contain raise statements + + # @return [RuboCop::AST::Node] + attr_reader :node + + # @return [MethodDeclaration, ClassDeclaration, ...] adjust type based on valid parents + attr_reader :parent + + # @param node [RuboCop::AST::Node] the AST node + # @param parent [...] the parent declaration + def initialize(node, parent) + @node = node + @parent = parent + end + + # NOTE on parent naming: Most declarations use `parent`, but some use + # domain-specific names with an alias: + # - MethodDeclaration: `parent_class` (alias :parent :parent_class) + # - ClassDeclaration: `file_declaration` (no alias — uses `file_declaration`) + # - RequireDeclaration: `parent_file` (alias :parent :parent_file) + # FilePathProvider traverses via `parent`, `file_declaration`, and `parent_class`, + # so if you use a custom name, add `alias :parent :` to ensure + # FilePathProvider can walk the tree. + + # REQUIRED: Used by matchers for failure messages. + # @return [String] + def name + # Return a meaningful identifier for this declaration + end + + # Add domain-specific public methods here. + # Each must have YARD @return tags. + end + end +end +``` + +**Rules:** +- `node` and `parent` are always `attr_reader` — providers depend on them +- `FilePathProvider`, `LineNumberProvider`, `ClassNameProvider` are **required** — matchers use `file_path`, `line`, and `class_name` for failure messages +- A `name` method is **required** — matchers call it via `element_name` +- Zeitwerk autoloads — no `require` statements needed (exception: if the file is loaded before Zeitwerk, add explicit `require_relative`) + +## Step 3: Create the Provider + +Create `lib/rubyzen/providers/_provider.rb` (plural name). + +```ruby +module Rubyzen + module Providers + # YARD: Brief description. + module Provider + # @return [Collection] + def + matching_nodes = node.each_descendant(:).select do |n| + # optional filtering condition + true + end + + declarations = matching_nodes.map do |n| + Declarations::Declaration.new(n, self) + end + + Collections::Collection.new(declarations) + end + end + end +end +``` + +**Rules:** +- The method name is plural (e.g., `yields`, `case_statements`, `loops`) +- Always returns a typed Collection, never a raw Array +- Uses `node.each_descendant` to find relevant AST nodes +- Creates declarations with `self` as parent so `FilePathProvider` can traverse upward + +## Step 4: Create the Collection + +Create `lib/rubyzen/collections/_collection.rb`. + +```ruby +module Rubyzen + module Collections + # YARD: Brief description. + # + # @example + # = method. + # .with_name("foo") + # + class Collection < BaseCollection + include Rubyzen::Providers::CollectionFilterProvider + + # Add domain-specific filter methods here. + # Each filter method MUST use `filter` (not `select` or `reject`). + # `filter` preserves the collection type for chaining. + + # @param some_value [String] + # @return [Collection] + def with_some_filter(some_value) + filter { |decl| decl.some_method == some_value } + end + end + end +end +``` + +**Rules:** +- Always extend `BaseCollection` +- Always include `CollectionFilterProvider` (provides `with_name`, `without_name`, etc.) +- Filter methods use `filter { }`, **never** `select` or `reject` (they are undefined on BaseCollection) +- Filter methods return the same collection type (this happens automatically via `filter`) + +## Step 5: Integrate + +Include the provider in the declarations that should expose this concept: + +```ruby +# In the declaration file, add: +include Rubyzen::Providers::Provider +``` + +**Where to include it** — think about where this concept appears in Ruby code: +- In method bodies → include in `MethodDeclaration` +- In class bodies → include in `ClassDeclaration` +- In blocks → include in `BlockDeclaration` +- At file level → include in `FileDeclaration` +- Multiple places → include in all relevant declarations + +**Add bridge methods** to collections that should aggregate the data: + +```ruby +# In methods_collection.rb (if provider is on MethodDeclaration): +def + Collection.new(flat_map(&:)) +end +``` + +Add bridge methods to every collection whose elements include the provider. For example: +- If `MethodDeclaration` includes the provider → add bridge to `MethodsCollection` +- If `ClassDeclaration` includes the provider → add bridge to `ClassesCollection` +- Users chain upward naturally: `classes.all_methods.` works without a bridge on `ClassesCollection` because the bridge is on `MethodsCollection` + +Only add a bridge to a collection if its elements **directly** have the provider. Don't add redundant bridges that just delegate through another bridge. + +## Step 6: Write Tests + +### Declaration spec (`spec/declarations/_declaration_spec.rb`) + +```ruby +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::Declaration do + # Test every public method on the declaration. + # Use inline Ruby snippets via parse_ruby helper. + + it 'returns the name' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + # ruby code that contains the concept + end + end + RUBY + + decl = file.classes.first.instance_methods.first..first + expect(decl.name).to eq('expected_name') + end + + # Test all other public methods... + + # Test provider methods (file_path, line, class_name, source_code, etc.) + it 'returns the file path' do + file = parse_ruby(<<~RUBY, file_path: 'app/models/user.rb') + class User + def foo + # concept here + end + end + RUBY + + decl = file.classes.first.instance_methods.first..first + expect(decl.file_path).to eq('app/models/user.rb') + end +end +``` + +### Collection spec (`spec/collections/_collection_spec.rb`) + +```ruby +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::Collection do + # Test CollectionFilterProvider methods + it 'filters by name' do + # ... + expect(collection.with_name('target')).to contain_exactly(...) + end + + # Test domain-specific filter methods + it 'filters with custom filter' do + # ... + end + + # Test that filter returns same collection type + it 'returns the same collection type from filter' do + # ... + expect(result).to be_a(described_class) + end +end +``` + +### Critical testing gotcha + +**Single-statement AST root:** When a Ruby snippet has only one statement, the AST root IS that statement. `each_descendant` won't find it because it only searches children, not the root itself. Always include at least two statements: + +```ruby +# BAD — yields nothing because root IS the :send node +file = parse_ruby('require "json"') +file.requires # => empty! + +# GOOD — root is :begin, each_descendant finds both children +file = parse_ruby("require 'json'\nx = 1") +file.requires # => [RequireDeclaration] +``` + +This affects any provider that uses `each_descendant` on file-level nodes. + +## Step 7: Update Documentation + +1. Add the new declaration to the **Declaration Reference** table in `CLAUDE.md` +2. Add the new collection to the **Data Flow** tree in `CLAUDE.md` +3. Run `bundle exec rspec spec/` to verify all tests pass + +## Checklist + +Before considering the work complete, verify: + +- [ ] Declaration includes `FilePathProvider`, `LineNumberProvider`, `ClassNameProvider` +- [ ] Declaration has a `name` method +- [ ] Declaration has `attr_reader :node, :parent` +- [ ] All public methods have YARD `@return` tags +- [ ] Provider method returns a typed Collection (not Array) +- [ ] Collection extends `BaseCollection` and includes `CollectionFilterProvider` +- [ ] Collection filter methods use `filter` (not `select`/`reject`) +- [ ] Provider is included in all relevant declarations +- [ ] Bridge methods added to parent collections +- [ ] Declaration spec tests every public method +- [ ] Collection spec tests `CollectionFilterProvider` methods and domain-specific filters +- [ ] Test snippets have 2+ statements (single-statement gotcha) +- [ ] `CLAUDE.md` updated +- [ ] `bundle exec rspec spec/` passes diff --git a/.claude/skills/run-lint-rules/SKILL.md b/.claude/skills/run-lint-rules/SKILL.md new file mode 100644 index 0000000..674fd87 --- /dev/null +++ b/.claude/skills/run-lint-rules/SKILL.md @@ -0,0 +1,66 @@ +--- +name: run-lint-rules +description: Run the sample project lint rules to verify that Rubyzen detects architectural violations. Use this skill when the user wants to run lint rules, verify that violations are detected, or test that a new lint rule works. Also trigger when the user says "run the lint rule", "check violations", or "test the sample project". +--- + +# Running Sample Project Lint Rules + +The sample project (`sample_project/`) contains intentional architectural violations. Lint rule specs verify that Rubyzen correctly detects them. + +## Run All Lint Rules + +```bash +bundle exec rspec sample_project/spec/ +``` + +**Expected behavior:** Tests are expected to **fail** — the sample project intentionally contains violations to demonstrate Rubyzen's detection capabilities. + +## Run a Specific Lint Rule + +```bash +bundle exec rspec sample_project/spec/controllers/no_if_statements_in_controllers_lint_spec.rb +``` + +## Lint Rule Structure + +``` +sample_project/ +├── src/ # Sample app with intentional violations +│ ├── controllers/ +│ ├── models/ +│ ├── presenters/ +│ ├── repos/ +│ ├── services/ +│ ├── requests/ +│ ├── tests/ +│ └── config.rb +└── spec/ # Lint rules as RSpec tests + ├── spec_helper.rb # Shared context with collection helpers + ├── controllers/ + ├── models/ + ├── presenters/ + ├── tests/ + └── ... +``` + +## Shared Context + +Lint rules use a shared context defined in `sample_project/spec/spec_helper.rb` that provides pre-built collections: + +```ruby +let(:controllers) { project.files.with_paths('src/controllers/').classes } +let(:models) { project.files.with_paths('src/models/').classes } +let(:services) { project.files.with_paths('src/services/').classes } +``` + +## Environment Setup + +Lint rules require `RUBYZEN_PROJECT_PATHS` to point at the source to analyze: + +```bash +# Required: comma-separated absolute paths +export RUBYZEN_PROJECT_PATHS="/path/to/src,/path/to/spec" + +# Legacy: single directory (still supported) +export RUBYZEN_PROJECT_PATH="/path/to/src" +``` diff --git a/.claude/skills/run-tests/SKILL.md b/.claude/skills/run-tests/SKILL.md new file mode 100644 index 0000000..2c7d5c3 --- /dev/null +++ b/.claude/skills/run-tests/SKILL.md @@ -0,0 +1,59 @@ +--- +name: run-tests +description: Run Rubyzen's unit test suite. Use this skill when the user wants to run tests, verify changes, check if an API is broken, or confirm that specs pass. Also trigger when the user says "run specs", "run the tests", "does it pass?", or "check the tests". +--- + +# Running Rubyzen Unit Tests + +Unit tests verify the correctness of Rubyzen's own API — declarations, providers, collections, and matchers. + +## Run All Tests + +```bash +bundle exec rspec spec/ +``` + +## Run a Specific File + +```bash +bundle exec rspec spec/declarations/class_declaration_spec.rb +``` + +## Run a Specific Category + +```bash +# All declaration tests +bundle exec rspec spec/declarations/ + +# All collection tests +bundle exec rspec spec/collections/ + +# All matcher tests +bundle exec rspec spec/matchers/ + +# All provider tests (covered within declaration specs) +bundle exec rspec spec/declarations/ +``` + +## Test Structure + +``` +spec/ +├── spec_helper.rb # Loads Rubyzen, includes parse helper +├── support/ +│ └── parse_helper.rb # parse_ruby helper for inline snippets +├── fixtures/ # Small .rb files for Project path tests +├── declarations/ # One spec per declaration type +├── collections/ # One spec per collection type +├── matchers/ # One spec per matcher +├── project_spec.rb # Project class tests +└── cache/ + └── parse_cache_spec.rb # Caching behavior tests +``` + +## Interpreting Failures + +- Matcher specs test the custom RSpec matchers (`be_empty`, `be_true`, `be_false`) +- Declaration specs test that AST nodes are correctly wrapped and exposed +- Collection specs test filtering, chaining, and bridge methods +- If a test fails after adding a new concept, check that providers are included in the right declarations and that bridge methods return the correct collection type diff --git a/.claude/skills/write-lint-rule/SKILL.md b/.claude/skills/write-lint-rule/SKILL.md new file mode 100644 index 0000000..2f7da06 --- /dev/null +++ b/.claude/skills/write-lint-rule/SKILL.md @@ -0,0 +1,253 @@ +--- +name: write-lint-rule +description: Write an architectural lint rule for a Ruby project using the Rubyzen API. Use this skill when the user wants to create a new lint rule, enforce an architectural constraint, add a code quality check, or write an RSpec test that validates code structure. Also trigger when the user says things like "controllers shouldn't do X", "models must always Y", "enforce that Z", or "add a rule for". +--- + +# Writing a Lint Rule with Rubyzen + +You are writing an architectural lint rule as an RSpec test using Rubyzen's high-level API. Lint rules enforce structural constraints on a Ruby codebase without touching raw AST. + +## Step 0: Understand the Available API + +Read `CLAUDE.md` to understand the full API surface. Pay special attention to: +- The **Data Flow** tree (what collections are available and how they chain) +- The **Declaration Reference** table (what methods each declaration exposes) +- The **Matchers** section (what assertions are available) + +Also read `sample_project/spec/spec_helper.rb` to see how collections are set up. + +## Step 1: Translate the Rule to API Calls + +Every lint rule follows this mental model: + +``` +SCOPE → FILTER → EXTRACT → ASSERT +``` + +1. **SCOPE**: Start from `project.files` and narrow to the right files +2. **FILTER**: Chain collection methods to isolate the code you care about +3. **EXTRACT**: Get the specific declarations to check +4. **ASSERT**: Use a matcher to enforce the constraint + +**Examples of translating English rules to API calls:** + +| English Rule | Rubyzen API | +|---|---| +| "Controllers must not call `.where`" | `controllers.all_methods.call_sites.with_name('where')` → `be_empty` | +| "Models must not define methods ending with `?`" | `models.all_methods.with_name_ending_with('?')` → `be_empty` | +| "All service classes must inherit from BaseService" | `services` → `be_true { \|k\| k.superclass_name == 'BaseService' }` | +| "Presenters must not use repository classes" | `presenters.all_methods.call_sites.with_receiver('UserRepo')` → `be_empty` | +| "No class should have more than 200 lines" | `all_classes` → `be_true { \|k\| k.lines_of_code <= 200 }` | +| "Every public method must have at most 3 parameters" | `methods.filter(&:public?)` → `be_true { \|m\| m.parameters.size <= 3 }` | + +## Step 2: Set Up the Shared Context + +Lint rules use a shared context in `spec_helper.rb` that provides pre-built collections. Read the existing `spec_helper.rb` in the target project's spec directory. If it doesn't exist, create one: + +```ruby +require 'rubyzen' + +RSpec.configure do |config| + RSpec.shared_context 'project_config' do + let(:project) { Rubyzen::Project.new } + let(:files) { project.files.with_paths('src/') } + + # Scope collections by directory + let(:controllers) { project.files.with_paths('src/controllers/').classes } + let(:models) { project.files.with_paths('src/models/').classes } + let(:services) { project.files.with_paths('src/services/').classes } + let(:repos) { project.files.with_paths('src/repos/').without_paths('/spec/').classes } + let(:test_files) { project.files.with_paths('spec/') } + # Add more as needed... + end + + # Auto-include in all specs so you don't need include_context in every file + config.include_context 'project_config' +end +``` + +## Step 3: Write the Lint Rule + +Create a spec file in the appropriate directory. If the shared context is auto-included via `config.include_context`, you don't need to include it manually: + +```ruby +require_relative '../spec_helper' + +RSpec.describe 'Rule: Controllers must not call ActiveRecord directly' do + # `controllers`, `models`, etc. are available from the shared context + + it 'controllers do not use .where' do + violations = controllers + .all_methods + .call_sites + .with_name('where') + + expect(violations).to be_empty + end + + it 'controllers do not use .find_by' do + violations = controllers + .all_methods + .call_sites + .with_name('find_by') + + expect(violations).to be_empty + end +end +``` + +## Available Matchers + +### `be_empty` +Asserts the collection has no elements. Use for "must not" / "should never" rules. Supports `allowlist:` and `baseline:` for gradual adoption. + +```ruby +expect(violations).to be_empty +expect(violations).to be_empty("Custom failure message explaining the rule") + +# Allowlist: permanently accepted exceptions +expect(violations).to be_empty(allowlist: ['LegacyController']) + +# Baseline: existing violations to fix over time (stale entries cause failure) +expect(violations).to be_empty(baseline: ['OldController', 'AncientController']) +``` + +Entries match against `name`, `class_name`, `file_path`, or `file_path:line`. Stale entries (listed but no longer violating) cause test failure — this prevents the baseline from going stale. + +### `be_true { |item| ... }` +Asserts the block returns `true` for ALL elements. Use for "must always" / "every X should" rules. Supports `allowlist:` and `baseline:` for items that fail the check. + +```ruby +expect(services).to be_true { |klass| klass.superclass_name == 'BaseService' } + +# With baseline for gradual adoption +expect(services).to be_true(baseline: ['LegacyService']) { |klass| klass.superclass_name == 'BaseService' } +``` + +**IMPORTANT:** Use `{ }` braces, NOT `do...end`. Due to Ruby operator precedence, `do...end` binds to `expect()` instead of the matcher, causing a silent bug. + +### `be_false { |item| ... }` +Asserts the block returns `false` for ALL elements. Inverse of `be_true`. Supports `allowlist:` and `baseline:` for items that fail the check. + +```ruby +expect(models).to be_false { |m| m.name.end_with?('Helper') } + +# With baseline for gradual adoption +expect(models).to be_false(baseline: ['OldHelper']) { |m| m.name.end_with?('Helper') } +``` + +### `be_empty_with_exceptions` (deprecated) +**Deprecated.** Use `be_empty(allowlist:, baseline:)` instead — it now supports all the same capabilities. + +```ruby +# Before (deprecated): +expect(violations).to be_empty_with_exceptions(allowlist: ['LegacyController']) + +# After (preferred): +expect(violations).to be_empty(allowlist: ['LegacyController']) +``` + +## Common API Patterns + +### Filtering by file path +```ruby +files.with_paths('app/controllers/') # files whose path includes this string +files.without_paths('/spec/', '/test/') # exclude test files +``` + +### Filtering by name +```ruby +classes.with_name('UsersController') +classes.with_name_ending_with('Controller') +classes.with_name_starting_with('Admin') +classes.with_name_including('Service') +classes.without_name('BaseController') +``` + +### Traversing the hierarchy +```ruby +classes.all_methods # MethodsCollection (instance + class methods) +classes.all_methods.call_sites # CallSiteCollection across all methods +classes.all_methods.parameters # ParametersCollection +classes.attributes # AttributesCollection +classes.macros # MacrosCollection +classes.macros.with_name('belongs_to') # filter macros by name +``` + +### Call site analysis +```ruby +call_sites.with_receiver('User') # calls on specific receiver +call_sites.with_name('where') # calls to specific method +call_sites.with_symbol(:admin) # calls with specific symbol arg +call_sites.with_keyword_arg(:foreign_key) # calls with specific keyword arg +``` + +### Method properties +```ruby +methods.filter(&:private?) # private methods +methods.filter(&:public?) # public methods +method.lines_of_code # number of lines +method.parameters # ParametersCollection +method.call_sites # CallSiteCollection +method.if_statements # DeclarationCollection +method.rescues # RescuesCollection +method.raises # RaisesCollection +``` + +### Class properties +```ruby +klass.superclass_name # parent class name +klass.superclass_prefix?('Base') # inheritance check +klass.top_level_module # e.g., "Admin" for Admin::UsersController +klass.instance_methods # only instance methods +klass.class_methods # only class methods +``` + +### Combining collections +```ruby +# Use + to merge collections of the same type +(jobs + services).all_methods.call_sites # call sites across both +(controllers + presenters).all_methods # methods from both layers +``` + +### Custom filtering with `filter` +For complex rules that can't be expressed with built-in filters, use `filter` with a block: + +```ruby +# Find calls to deliver that have request_guid but not remote_addr +deliver_calls = call_sites + .with_receiver('Relay') + .with_method_name('deliver') + +violations = deliver_calls.filter do |site| + site.keyword_args.include?(:request_guid) && + !site.keyword_args.include?(:remote_addr) +end + +expect(violations).to be_empty +``` + +### Block analysis +```ruby +file.blocks # top-level blocks +file.blocks.with_method_name('describe') # RSpec describe blocks +block.call_sites # calls within the block +``` + +## Failure Messages + +Rubyzen matchers automatically format failure messages with: +- Element name (method name, class name, etc.) +- Class name (parent class) +- File path and line number + +This makes violations immediately actionable — developers see exactly where the violation is. + +## Checklist + +- [ ] Rule file is in the correct spec directory +- [ ] Shared context is available (auto-included or via `include_context 'project_config'`) +- [ ] Uses `be_empty` for "must not" rules, `be_true { }` for "must always" rules +- [ ] Uses `{ }` braces (not `do...end`) with `be_true`/`be_false` +- [ ] Custom failure message explains *why* the rule exists (optional but recommended) +- [ ] `bundle exec rspec path/to/spec` runs successfully diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 0000000..a7b1227 --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,34 @@ +# Dev Container Setup + +Rubyzen's dev container allows you to lint a project without manually setting `RUBYZEN_PROJECT_PATHS`. It mounts a sibling directory as the target directory and configures the environment automatically. + +## Directory Structure + +Rubyzen and the project you want to lint must live in the same parent directory: + +``` +~/parent-folder/ +├── Rubyzen/ (this project) +├── YourProject/ (the project you want to lint) +``` + +## Quick Start + +From your host machine, inside the Rubyzen directory: + +```bash +cd path/to/Rubyzen +export RUBYZEN_TARGET_PROJECT=YourProject +devcontainer open . +``` + +As a result, the dev container will automatically mount `../YourProject` into `/workspaces/target_project` and set `RUBYZEN_PROJECT_PATHS` to `/workspaces/target_project/src,/workspaces/target_project/spec`. + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| "Environment variable not set" error | `export RUBYZEN_TARGET_PROJECT=YourProject` before `code .` | +| "Target project not found" | Verify `../$RUBYZEN_TARGET_PROJECT` exists and rebuild container | +| Mount errors on startup | Check env var is set, target exists, then rebuild container | +| Changed env var but still seeing old project | Rebuild dev container to update mount path | diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 120000 index 0000000..949a29f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +../CLAUDE.md \ No newline at end of file diff --git a/.github/skills b/.github/skills new file mode 120000 index 0000000..454b842 --- /dev/null +++ b/.github/skills @@ -0,0 +1 @@ +../.claude/skills \ No newline at end of file diff --git a/.github/workflows/rubyzen-analysis.yml b/.github/workflows/rubyzen-analysis.yml deleted file mode 100644 index c8f0192..0000000 --- a/.github/workflows/rubyzen-analysis.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Rubyzen Analysis - -on: - pull_request: - branches: [ main, develop ] - workflow_dispatch: - -jobs: - analyze: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Run RubyZen Analysis - uses: ./ - with: - token: ${{ secrets.GITHUB_TOKEN }} - ruby-version: '3.3' - target-directories: 'sample_project/src' - target-rspec-directory: 'sample_project/spec' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..97947f5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,21 @@ +name: Tests + +on: + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + + - name: Run unit tests + run: bundle exec rspec spec/ diff --git a/CLAUDE.md b/CLAUDE.md index c06f7e0..603989e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,90 +1,253 @@ -# CLAUDE.md +# Rubyzen Architecture Guide -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## What is Rubyzen -## Commands +Rubyzen is a Ruby architectural linter that lets you write lint rules as RSpec tests. Inspired by Konsist (Kotlin) and Harmonize (Swift), it wraps RuboCop AST to provide a high-level, easy-to-use API for enforcing architectural rules across a codebase. -### Testing and Analysis -```bash -# Run Rubyzen lint rules on the sample project -bundle exec rspec sample_project/spec/ - -# Run Rubyzen lint rules on a target project -bundle exec rspec target_project/spec/rubyzen/ +Instead of configuring YAML rules, you write standard RSpec tests: -# Run specific lint rule -bundle exec rspec sample_project/spec/controllers/controllers_must_have_tests_lint_spec.rb +```ruby +it 'controllers do not call ActiveRecord directly' do + expect(controllers.all_methods.call_sites.with_name('where')).to be_empty +end ``` -### Environment Setup -- Set `RUBYZEN_PROJECT_PATHS` environment variable to comma-separated absolute paths of directories to analyze (e.g., "/path/to/src,/path/to/spec") -- Alternatively, set `RUBYZEN_PROJECT_PATH` for single directory (legacy support) -- For dev container usage, set `RUBYZEN_TARGET_PROJECT` to specify which sibling project to mount +## Core Concepts + +Rubyzen has four main building blocks: -## Architecture +| Concept | Purpose | Example | +|---|---|---| +| **Declarations** | Domain objects wrapping AST nodes | `ClassDeclaration`, `MethodDeclaration`, `CallSiteDeclaration` | +| **Collections** | Typed arrays of declarations with filtering/aggregation | `ClassesCollection`, `MethodsCollection` | +| **Providers** | Mixins that add capabilities to declarations | `CallSiteProvider`, `BlocksProvider` | +| **Matchers** | RSpec matchers for asserting on collections | `be_empty`, `be_true { }`, `be_false { }` | -Rubyzen is a Ruby architectural linter that allows writing lint rules as unit tests, inspired by Konsist (Kotlin) and Harmonize (Swift). It uses RuboCop AST under the hood but provides a high-level API for architectural rule enforcement. +## Data Flow -### Core Components +``` +Project + └── files → FileCollection + ├── .classes → ClassesCollection + │ ├── .all_methods → MethodsCollection + │ │ ├── .parameters → ParametersCollection + │ │ ├── .call_sites → CallSiteCollection + │ │ ├── .if_statements → DeclarationCollection + │ │ ├── .rescues → RescuesCollection + │ │ └── .raises → RaisesCollection + │ ├── .attributes → AttributesCollection + │ ├── .macros → MacrosCollection + │ ├── .rescues → RescuesCollection + │ └── .raises → RaisesCollection + ├── .modules → ModulesCollection + ├── .call_sites → CallSiteCollection + ├── .blocks → BlocksCollection + │ └── .call_sites → CallSiteCollection + ├── .constants → ConstantsCollection + └── .requires → RequiresCollection +``` -**Main Entry Point:** -- `lib/rubyzen.rb` - Main module with configuration system that requires `RUBYZEN_PROJECT_PATH` environment variable +Every arrow is a method that returns a typed collection. Collections support chaining via filtering methods. -**Project Analysis:** -- `lib/rubyzen/project.rb` - Main project analyzer that parses Ruby files and provides access to files and classes +## Folder Structure -**Collections (High-level API):** -- `lib/rubyzen/collections/base_collection.rb` - Base collection functionality -- `lib/rubyzen/collections/file_collection.rb` - File-based operations and filtering -- `lib/rubyzen/collections/classes_collection.rb` - Class-based operations -- `lib/rubyzen/collections/methods_collection.rb` - Method-based operations -- `lib/rubyzen/collections/call_site_collection.rb` - Call site analysis -- `lib/rubyzen/collections/declaration_collection.rb` - Declaration handling +``` +lib/rubyzen/ +├── rubyzen.rb # Entry point, Zeitwerk loader, configuration +├── project.rb # Parses all .rb files, returns FileCollection +├── declarations/ # Domain objects wrapping AST nodes +│ ├── file_declaration.rb +│ ├── class_declaration.rb +│ ├── module_declaration.rb +│ ├── method_declaration.rb +│ ├── parameter_declaration.rb +│ ├── call_site_declaration.rb +│ ├── block_declaration.rb +│ ├── constant_declaration.rb +│ ├── require_declaration.rb +│ ├── attribute_declaration.rb +│ ├── if_statement_declaration.rb +│ ├── macro_declaration.rb +│ ├── raise_declaration.rb +│ └── rescue_declaration.rb +├── collections/ # Typed arrays with filtering/aggregation +│ ├── base_collection.rb # Extends Array, provides filter method +│ ├── file_collection.rb +│ ├── classes_collection.rb +│ ├── modules_collection.rb +│ ├── methods_collection.rb +│ ├── parameters_collection.rb +│ ├── call_site_collection.rb +│ ├── blocks_collection.rb +│ ├── constants_collection.rb +│ ├── requires_collection.rb +│ ├── attributes_collection.rb +│ ├── macros_collection.rb +│ ├── raises_collection.rb +│ ├── rescues_collection.rb +│ └── declaration_collection.rb +├── providers/ # Mixins included in declarations +│ ├── file_path_provider.rb +│ ├── line_number_provider.rb +│ ├── lines_of_code_provider.rb +│ ├── class_name_provider.rb +│ ├── source_code_provider.rb +│ ├── call_site_provider.rb +│ ├── blocks_provider.rb +│ ├── if_statements_provider.rb +│ ├── constants_provider.rb +│ ├── requires_provider.rb +│ ├── attributes_provider.rb +│ ├── macros_provider.rb +│ ├── raises_provider.rb +│ ├── rescues_provider.rb +│ ├── visibility_provider.rb +│ └── collection_filter_provider.rb +├── matchers/ # RSpec custom matchers +│ ├── matcher_helpers.rb +│ ├── be_empty_matcher.rb +│ ├── be_empty_with_exceptions_matcher.rb +│ ├── be_true_matcher.rb +│ └── be_false_matcher.rb +├── parsers/ +│ └── a_s_t_parser.rb # Wraps RuboCop AST ProcessedSource +├── cache/ +│ └── parse_cache.rb # SHA256-based in-memory parse cache +└── rspec/ + └── rspec_config.rb # Validates expect() subjects are collections + +sample_project/ +├── src/ # Sample app with intentional violations +│ ├── controllers/ +│ ├── models/ +│ ├── presenters/ +│ ├── repos/ +│ ├── services/ +│ ├── requests/ +│ ├── tests/ +│ └── config.rb +└── spec/ # Lint rules as RSpec tests + ├── spec_helper.rb # Shared context with common collections + ├── controllers/ + ├── models/ + ├── presenters/ + ├── tests/ + └── ... +``` -**AST Parsing:** -- `lib/rubyzen/parsers/ast_parser.rb` - Wraps RuboCop AST for parsing Ruby code +## How the Pieces Connect -**Code Declarations:** -- `lib/rubyzen/declarations/` - Represents different code structures (classes, methods, files, blocks, if statements, call sites) +### Declarations include Providers -**Providers:** -- `lib/rubyzen/providers/` - Extract specific information from AST (blocks, call sites, class names, constants, file paths, if statements, line numbers, lines of code) +Each declaration includes providers as mixins to gain capabilities. The `node` and `parent` attributes are used by providers to traverse the AST. -**Matchers:** -- `lib/rubyzen/matchers/` - Custom matchers for architectural rules (be_empty, be_true, be_false) +```ruby +class MethodDeclaration + include Rubyzen::Providers::CallSiteProvider # adds .call_sites + include Rubyzen::Providers::BlocksProvider # adds .blocks + include Rubyzen::Providers::IfStatementsProvider # adds .if_statements + # ... +end +``` -**Caching:** -- `lib/rubyzen/cache/parse_cache.rb` - Caches parsed AST for performance +### Providers return Collections -### Sample Project Structure -- `sample_project/src/` - Sample Ruby application with different architectural layers (controllers, models, presenters, repos, actions) -- `sample_project/spec/` - Lint rules written as RSpec tests, organized by architectural layer +Providers create typed collections from AST node descendants: -### RSpec Integration -- Uses shared context `project_config` in spec_helper.rb that provides common collections (controllers, presenters, models, repos, actions, test_files) -- Lint rules are written as standard RSpec tests using Rubyzen's API +```ruby +module CallSiteProvider + def call_sites + Collections::CallSiteCollection.new( + node.each_descendant(:send).map { |n| Declarations::CallSiteDeclaration.new(n, self) } + ) + end +end +``` -### GitHub Action Integration -- `action.yml` - GitHub Action for running Rubyzen analysis in CI/CD -- Configurable target directory and RSpec directory -- Outputs violations found and full analysis results +### Collections bridge to other Collections -## Development Patterns +Collections aggregate their elements' sub-collections via bridge methods: -### Writing Lint Rules -Lint rules are written as RSpec tests using the shared context. Example pattern: ```ruby -describe 'Architectural Rule' do - it 'enforces the rule' do - expect(controllers.that { have_call_sites_with_names('.where') }).to be_empty +class MethodsCollection < BaseCollection + def call_sites + CallSiteCollection.new(flat_map(&:call_sites)) end end ``` -### Path Filtering -Collections support path-based filtering: -- `.with_paths('src/controllers/')` - Include only files matching pattern -- `.without_paths('/spec/')` - Exclude files matching pattern +### BaseCollection + +All collections extend `BaseCollection`, which extends `Array` with: +- `filter` method that returns the same collection type (critical for chaining) +- Removes `select` and `reject` to enforce using `filter` + +### CollectionFilterProvider + +All collections include `CollectionFilterProvider`, which adds name-based filtering: +- `with_name`, `without_name` +- `with_name_starting_with`, `with_name_ending_with`, `with_name_including` +- And their `without_` counterparts + +Some collections add domain-specific filters (e.g., `CallSiteCollection.with_symbol`, `FileCollection.with_paths`). + +## Declaration Reference + +Each declaration wraps an AST node and exposes domain-specific methods: + +| Declaration | Key Methods | Providers | +|---|---|---| +| `FileDeclaration` | `name`, `classes`, `modules` | FilePathProvider, LinesOfCodeProvider, ConstantsProvider, RequiresProvider, CallSiteProvider, BlocksProvider | +| `ClassDeclaration` | `name`, `superclass_name`, `instance_methods`, `class_methods`, `top_level_module` | FilePathProvider, ClassNameProvider, LinesOfCodeProvider, ConstantsProvider, AttributesProvider, MacrosProvider, BlocksProvider, IfStatementsProvider, RescuesProvider, RaisesProvider | +| `MethodDeclaration` | `name`, `parameters`, `parameters?` | FilePathProvider, ClassNameProvider, LinesOfCodeProvider, CallSiteProvider, BlocksProvider, IfStatementsProvider, ConstantsProvider, VisibilityProvider, RescuesProvider, RaisesProvider | +| `CallSiteDeclaration` | `name`, `receiver`, `method_name`, `keyword_args`, `keyword_arg_value_pairs`, `symbols`, `strings` | FilePathProvider, LineNumberProvider, ClassNameProvider, SourceCodeProvider | +| `BlockDeclaration` | `name`, `method_name` | FilePathProvider, LineNumberProvider, ClassNameProvider, LinesOfCodeProvider, SourceCodeProvider, CallSiteProvider, RescuesProvider, RaisesProvider | +| `ParameterDeclaration` | `name`, `default_value` | FilePathProvider, LineNumberProvider, ClassNameProvider | +| `ConstantDeclaration` | `name`, `value`, `assignment?`, `reference?`, `top_level?` | FilePathProvider, LineNumberProvider, ClassNameProvider, SourceCodeProvider | +| `AttributeDeclaration` | `name`, `symbols`, `reader?`, `writer?`, `accessor?` | FilePathProvider, ClassNameProvider, LineNumberProvider, VisibilityProvider | +| `MacroDeclaration` | `name`, `symbols`, `strings`, `keyword_args`, `receiver` | FilePathProvider, ClassNameProvider, LineNumberProvider, SourceCodeProvider | +| `RequireDeclaration` | `name`, `required_path`, `require?`, `require_relative?` | FilePathProvider, LineNumberProvider | +| `IfStatementDeclaration` | `name`, `condition_source` | FilePathProvider, ClassNameProvider, LineNumberProvider, SourceCodeProvider | +| `RaiseDeclaration` | `exception_types`, `with_string?`, `message` | FilePathProvider, LineNumberProvider, ClassNameProvider, SourceCodeProvider | +| `RescueDeclaration` | `exception_types` | FilePathProvider, LineNumberProvider, ClassNameProvider | + +## Matchers + +All matchers use `MatcherHelpers` for formatting failure messages with element name, class, and file location. + +| Matcher | Purpose | Usage | +|---|---|---| +| `be_empty` | Collection has no elements. Supports `allowlist:` and `baseline:` for gradual adoption. | `expect(violations).to be_empty` or `expect(violations).to be_empty(baseline: [...])` | +| `be_true { \|item\| }` | Block returns true for ALL elements. Supports `allowlist:` and `baseline:`. | `expect(methods).to be_true { \|m\| m.parameters.any? }` | +| `be_false { \|item\| }` | Block returns false for ALL elements. Supports `allowlist:` and `baseline:`. | `expect(methods).to be_false { \|m\| m.name == :biz }` | +| `be_empty_with_exceptions` | **Deprecated.** Use `be_empty(allowlist:, baseline:)` instead. | `expect(items).to be_empty_with_exceptions(baseline: [...])` | + +**Important:** Use `{ }` braces (not `do...end`) with `be_true`/`be_false` — `do...end` binds to `expect()` instead of the matcher due to Ruby precedence. + +## Environment Setup + +```bash +# Required: comma-separated absolute paths of the project folders to lint +export RUBYZEN_PROJECT_PATHS="/path/to/src,/path/to/spec" + +# Legacy: single directory (still supported) +export RUBYZEN_PROJECT_PATH="/path/to/src" +``` + +## GitHub Action Integration + +Rubyzen ships with a GitHub Action (`action.yml`) for running lint analysis in CI/CD: +- Configurable target directory and RSpec directory +- Outputs violations found and full analysis results + +## Skills + +The following skills are available in `.claude/skills/` to guide AI agents: -### Environment Configuration -The system requires `RUBYZEN_PROJECT_PATHS` (or `RUBYZEN_PROJECT_PATH` for single directory) to be set to the absolute path(s) of the project(s) to analyze. Multiple paths are comma-separated like the PATH environment variable. This allows the same Rubyzen installation to analyze different target projects and multiple directories within a project. +| Skill | Purpose | +|---|---| +| `run-tests` | Run Rubyzen's unit test suite | +| `run-lint-rules` | Run sample project lint rules and verify violation detection | +| `write-lint-rule` | Write an architectural lint rule using the Rubyzen API | +| `add-rubyzen-tests` | Write unit tests for Rubyzen's own components | +| `expand-rubyzen` | Add a new Rubyzen API (Declaration + Provider + Collection) | diff --git a/Gemfile b/Gemfile index 0ab91ca..2ad43cd 100644 --- a/Gemfile +++ b/Gemfile @@ -6,3 +6,4 @@ gem 'zeitwerk', '~> 2.6' gem 'pry', '~> 0.14.1' gem 'yaml', '~> 0.1.0' gem 'ostruct', '~> 0.6.2' # removed in Ruby 3.5, but pry requires it +gem 'yard', '~> 0.9' diff --git a/Gemfile.lock b/Gemfile.lock index 9369a3d..7180829 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -32,6 +32,7 @@ GEM prism (~> 1.7) yaml (0.1.1) zeitwerk (2.7.5) + yard (0.9.43) PLATFORMS aarch64-linux @@ -43,6 +44,7 @@ DEPENDENCIES rspec (~> 3.12) rubocop-ast (~> 1.26) yaml (~> 0.1.0) + yard (~> 0.9) zeitwerk (~> 2.6) BUNDLED WITH diff --git a/README.md b/README.md index a94919a..f7c9cab 100644 --- a/README.md +++ b/README.md @@ -1,116 +1,96 @@ # Rubyzen -Rubyzen is a prototype of a modern linter in Ruby that lets you write architectural lint rules as unit tests, inspired by tools like [Konsist](https://github.com/LemonAppDev/konsist) (for Kotlin) and [Harmonize](https://github.com/perrystreetsoftware/Harmonize) (for Swift). The goal is to explore the technical feasibility and the effort required to build such linter. +Rubyzen is an architectural linter for Ruby that lets you write architectural lint rules as unit tests, inspired by tools like [Konsist](https://github.com/LemonAppDev/konsist) (for Kotlin) and [Harmonize](https://github.com/perrystreetsoftware/Harmonize) (for Swift). -## Motivation +## Architectural linters in the era of AI-generated code -Code review feedback often includes architectural or structural comments ("Do not call repository methods from presenters", "Do not query the database from controllers", etc.). With Rubyzen, you can enforce these architectural rules as unit tests. This reduces the need for manual code reviews and allows the team to align and document their architecture and best practices. +In the era of AI-generated code, architectural flaws and subtle bugs happen faster than ever. AI agents produce code that passes tests and looks reasonable but subtly violates your team's architecture. As more code is produced, faster, it becomes impractical for manual code reviews to catch all these violations. + +Architectural lint rules act as deterministic guardrails. They catch the architectural or structural mistakes that AI introduces, such as calling the database from a presenter or performing business logic in a controller, before they get merged. And since they run as unit tests, they provide immediate feedback to the AI agents to fix their own code. ## Why Yet Another Linter? -Traditional linters such as [RuboCop](https://github.com/rubocop/rubocop) require dealing with the raw AST, which has a steep learning curve and makes the rules hard to write, hard to read, and maintain. The goal of Rubyzen is to abstract away the AST details, allowing developers to write rules in a more natural way, similarly as we write tests. This reduces complexity and improves readability and maintainability. +Traditional linters such as [RuboCop](https://github.com/rubocop/rubocop) require dealing with the raw AST, which has a steep learning curve and makes rules hard to write, read, and maintain. Rubyzen abstracts away the AST details, allowing developers to write rules in a more natural way — the same way we write tests. ## Advantages -- **Easy-to-Use API:** Rubyzen provides a friendly, high-level API to access files, classes, methods, dependencies, and more. This way developers do not have to manual access nodes and deal with low-level AST operations manually. - -- **Architectural Enforcement & Documentation:** By writing the lint rules as tests, we can use the Given-When-Then style and provide documentation for our architecture within the codebase, without having to maintain wiki pages or diagrams. +- **Readable, Easy-to-Use API:** Rubyzen provides a high-level API to access files, classes, methods, parameters, and more, without having to deal with low-level AST operations. -- **Less Manual Reviews:** With architectural rules automatically enforced by tests, code reviews can focus on more complex issues instead of repeating the same architectural feedback. +- **Architectural Enforcement & Documentation:** By writing lint rules as tests, you can use the Given-When-Then style and document your architecture within the codebase, without maintaining wiki pages or diagrams. -## How it Works +- **Less Manual Reviews:** With architectural rules automatically enforced, code reviews can focus on complex issues instead of repeating the same architectural feedback. -Rubyzen uses [RuboCop AST](https://github.com/rubocop/rubocop-ast) under the hood to parse the Ruby code into an AST. It then provides a simplified API to access classes, methods, dependencies, and other code structures. Since RuboCop AST can access any node or token, the API of Rubyzen can potentially cover any architectural rule that we want to enforce. +- **AI-Friendly Feedback Loop:** When lint rules fail, the failure messages tell AI agents exactly what they violated and where, allowing them to self-correct their code. -## Project Structure +## How it Works -- **`lib/rubyzen/`:** Contains Rubyzen's source code, including: - - `project.rb` - Main project analyzer - - `classes_collection.rb`, `methods_collection.rb`, `file_collection.rb` - Collections for code structures - - `parsers/`, `matchers/`, `providers/`, `declarations/`, `cache/` - Core functionality modules +Rubyzen uses [RuboCop AST](https://github.com/rubocop/rubocop-ast) under the hood to parse Ruby code into an AST. It then provides a simplified API to access classes, methods, or any other code structure. Since RuboCop AST can access any node or token, Rubyzen's API can cover any architectural rule you want to enforce. -- **`sample_project/src/`:** A sample Ruby project that Rubyzen can lint +## Example -- **`sample_project/spec/`:** Contains sample lint rules written as unit tests, demonstrating how to enforce architectural rules +```ruby +RSpec.describe 'Architecture rules' do + it 'controllers do not call ActiveRecord directly' do + violations = controllers + .all_methods + .call_sites + .with_name('where') -## Example Lint Rules + expect(violations).to be_empty + end -- Ensuring that non-repository classes do not use `.where` queries. -- Verifying that presenter classes do not directly access repositories. -- Prohibiting new additions to legacy files. -- Confirming that model classes do not use question-mark in methods, enforcing us to use the Ask pattern. + it 'all services inherit from BaseService' do + expect(services).to be_true { |s| s.superclass_name == 'BaseService' } + end +end +``` -### Running Lint Rules +## Project Structure -You should first start a devcontainer, with `devcontainer open .` run from the `Rubyzen` folder. +- **`lib/rubyzen/`:** Rubyzen's source code — declarations, collections, providers, matchers, parsers, and cache +- **`sample_project/src/`:** A sample Ruby project with intentional violations +- **`sample_project/spec/`:** Sample lint rules demonstrating architectural enforcement -To run lint rules, execute RSpec with the path to your rule specifications. The rules will analyze the currently mounted target project: +## Running tests ```bash -# Run sample lint rules against the target project -bundle exec rspec sample_project/spec/ - -# Run custom lint rules for the target project -bundle exec rspec target_project/spec/rubyzen/ +# Run unit tests (verify Rubyzen's own API works) +cd path/to/Rubyzen +bundle exec rspec spec/ ``` -The lint rules are project-agnostic - you can apply any rule set to any target project by specifying the appropriate spec path. - -## Dev Container Integration - -### Quick Start +## Running lint rules against the sample project -1. **Set target project environment variable** (REQUIRED): - ```bash - export RUBYZEN_TARGET_PROJECT=YourProjectName - code . - ``` - -**Note:** When changing the `RUBYZEN_TARGET_PROJECT` environment variable, you must rebuild the dev container for the change to take effect. The container will continue to mount the previous target directory until rebuilt. - -### Architecture - -**Environment-driven setup** - no configuration generation needed: -- `RUBYZEN_TARGET_PROJECT` environment variable specifies which sibling project to lint -- Target project mounts to fixed path: `/workspaces/target_project` -- All configs use static paths pointing to multiple directories: `/workspaces/target_project/src,/workspaces/target_project/spec` - -#### Directory Structure -``` -parent-folder/ -├── Rubyzen/ (this linter project) -├── YourProject/ (target project - set via env var) -└── OtherProject/ (another potential target) +```bash +# Run lint rules on the sample project (expected to fail — intentional violations) +cd path/to/Rubyzen +bundle exec rspec sample_project/spec/ ``` -#### Container Structure -``` -/workspaces/ -├── Rubyzen/ (this project) -└── target_project/ (mounted from $RUBYZEN_TARGET_PROJECT) - ├── src/ (Ruby source files to lint) - └── spec/ (Ruby test files to lint) -``` +## Running lint rules against your own project -### Usage Examples +To run the lint rules against your own project, set the `RUBYZEN_PROJECT_PATHS` env var to the folders you want to lint, and then run `bundle exec rspec`: ```bash -# Different projects -export RUBYZEN_TARGET_PROJECT=Husband-Redis && code . -export RUBYZEN_TARGET_PROJECT=MyClientApp && code . - -# Team setup with .env file -echo "RUBYZEN_TARGET_PROJECT=OurMainProject" > .env +cd path/to/Rubyzen +export RUBYZEN_PROJECT_PATHS="/path/to/your-project/src" +# Optionally include test files or other folders if you want to lint those too +# export RUBYZEN_PROJECT_PATHS="/path/to/your-project/src,/path/to/your-project/spec" +bundle exec rspec path/to/your-project-lint-rules ``` -### Troubleshooting +## AI Agent Skills + +Rubyzen includes agent skills in `.claude/skills/` (also symlinked at `.github/skills/`) that work with both Claude Code and GitHub Copilot: -| Issue | Solution | -|-------|----------| -| "Environment variable not set" error | `export RUBYZEN_TARGET_PROJECT=YourProject` before `code .` | -| "Target project not found" | Verify `../$RUBYZEN_TARGET_PROJECT` exists and rebuild container | -| Mount errors on startup | Check env var is set, target exists, then rebuild container | -| Changed env var but still seeing old project | Rebuild dev container to update mount path | +| Skill | Purpose | +|---|---| +| `run-tests` | Run Rubyzen's unit test suite | +| `run-lint-rules` | Run sample project lint rules and verify the violations are detected | +| `write-lint-rule` | Write an architectural lint rule using the Rubyzen API | +| `add-rubyzen-tests` | Write unit tests for Rubyzen's own components | +| `expand-rubyzen` | Add a new Rubyzen API (Declaration + Provider + Collection) | +## Dev Container (optional) -This project is an early prototype. The intent is to explore the technical feasibility and effort to build a modern architectural linter for Ruby. +Rubyzen includes a dev container that automatically mounts a sibling project and configures the environment for you. See [`.devcontainer/README.md`](.devcontainer/README.md) for setup instructions. diff --git a/lib/rubyzen.rb b/lib/rubyzen.rb index d26d2ca..d06b3ec 100644 --- a/lib/rubyzen.rb +++ b/lib/rubyzen.rb @@ -10,12 +10,39 @@ require_relative 'rubyzen/matchers/be_true_matcher' require_relative 'rubyzen/matchers/be_false_matcher' +# Rubyzen is a Ruby architectural linter that lets you write lint rules as RSpec tests. +# It wraps RuboCop AST to provide a high-level, easy-to-use API for enforcing architectural +# rules across a codebase. +# +# @example Basic usage +# project = Rubyzen::Project.new(["/path/to/src", "/path/to/spec"]) +# controllers = project.files.with_paths("controllers/").classes +# +# # Assert controllers don't call ActiveRecord directly +# expect(controllers.all_methods.call_sites.with_name("where")).to be_empty +# +# @example Using environment variable +# # Set RUBYZEN_PROJECT_PATHS="/path/to/src,/path/to/spec" +# project = Rubyzen::Project.new # reads from env var +# module Rubyzen + # Returns the global configuration, reading from the +RUBYZEN_PROJECT_PATHS+ environment variable. + # + # @return [Configuration] + # @raise [RuntimeError] if +RUBYZEN_PROJECT_PATHS+ is not set or contains invalid paths def self.configuration @configuration ||= Configuration.new end + # Reads and validates project paths from the +RUBYZEN_PROJECT_PATHS+ environment variable. + # + # @example + # ENV['RUBYZEN_PROJECT_PATHS'] = "/app/src,/app/spec" + # config = Rubyzen::Configuration.new + # config.project_paths #=> ["/app/src", "/app/spec"] + # class Configuration + # @return [Array] absolute paths to directories to analyze attr_reader :project_paths def initialize diff --git a/lib/rubyzen/cache/parse_cache.rb b/lib/rubyzen/cache/parse_cache.rb index 03202dd..865f6e3 100644 --- a/lib/rubyzen/cache/parse_cache.rb +++ b/lib/rubyzen/cache/parse_cache.rb @@ -2,11 +2,18 @@ module Rubyzen module Cache + # In-memory cache for parsed AST results, keyed by file path and SHA256 checksum. + # Automatically invalidates entries when file contents change. class ParseCache def initialize @cache = {} end + # Returns the cached result for the given file, or yields to parse and cache it. + # + # @param file_path [String] absolute path to the file + # @yield block that parses the file and returns the result to cache + # @return [Object] the cached or freshly parsed result def fetch_or_parse(file_path, &block) checksum = file_checksum(file_path) diff --git a/lib/rubyzen/collections/attributes_collection.rb b/lib/rubyzen/collections/attributes_collection.rb index 65281a5..7e10b92 100644 --- a/lib/rubyzen/collections/attributes_collection.rb +++ b/lib/rubyzen/collections/attributes_collection.rb @@ -3,17 +3,30 @@ module Rubyzen module Collections + # Collection of attribute declarations (attr_reader, attr_writer, attr_accessor). + # + # @example Ensuring no class uses attr_accessor + # expect(controllers.attributes.accessors).to be_empty class AttributesCollection < BaseCollection include Rubyzen::Providers::CollectionFilterProvider + # Returns only +attr_reader+ attributes. + # + # @return [AttributesCollection] def readers filter(&:reader?) end + # Returns only +attr_writer+ attributes. + # + # @return [AttributesCollection] def writers filter(&:writer?) end + # Returns only +attr_accessor+ attributes. + # + # @return [AttributesCollection] def accessors filter(&:accessor?) end diff --git a/lib/rubyzen/collections/base_collection.rb b/lib/rubyzen/collections/base_collection.rb index e755a3e..2cf3d30 100644 --- a/lib/rubyzen/collections/base_collection.rb +++ b/lib/rubyzen/collections/base_collection.rb @@ -1,9 +1,20 @@ module Rubyzen module Collections + # Base collection class for all Rubyzen collections. + # Extends Array and replaces +select+/+reject+ with a single +filter+ method + # that preserves the collection subclass type. + # + # @example Filtering a collection with a block + # controllers.filter { |c| c.name.end_with?('Controller') } class BaseCollection < Array undef_method(:select) undef_method(:reject) + # Filters elements by the given block, returning a new collection of the same type. + # + # @yield [element] block that returns truthy to keep the element + # @return [BaseCollection] a new collection containing only matching elements + # @return [Enumerator] if no block is given def filter return enum_for(:filter) unless block_given? diff --git a/lib/rubyzen/collections/blocks_collection.rb b/lib/rubyzen/collections/blocks_collection.rb index 5331ac5..84a0865 100644 --- a/lib/rubyzen/collections/blocks_collection.rb +++ b/lib/rubyzen/collections/blocks_collection.rb @@ -1,12 +1,23 @@ module Rubyzen module Collections + # Collection of block declarations (do...end / { }) found in files or methods. + # + # @example Finding blocks passed to a specific method + # project.files.blocks.with_method_name('describe') class BlocksCollection < BaseCollection include Rubyzen::Providers::CollectionFilterProvider + # Filters blocks by the method name they are passed to. + # + # @param method_name [String] the method name to match + # @return [BlocksCollection] def with_method_name(method_name) filter { |block| block.method_name == method_name } end + # Returns all call sites found inside every block. + # + # @return [CallSiteCollection] def call_sites all_call_sites = flat_map(&:call_sites) CallSiteCollection.new(all_call_sites) diff --git a/lib/rubyzen/collections/call_site_collection.rb b/lib/rubyzen/collections/call_site_collection.rb index dcb1d75..34749fc 100644 --- a/lib/rubyzen/collections/call_site_collection.rb +++ b/lib/rubyzen/collections/call_site_collection.rb @@ -1,25 +1,49 @@ module Rubyzen module Collections + # Collection of method call sites found in classes, methods, or blocks. + # + # @example Ensuring controllers do not call .where directly + # expect(controllers.that { have_call_sites_with_names('.where') }).to be_empty class CallSiteCollection < BaseCollection include Rubyzen::Providers::CollectionFilterProvider + # Filters call sites by the receiver expression. + # + # @param receiver [String] the receiver to match + # @return [CallSiteCollection] def with_receiver(receiver) filter { |call_site| call_site.receiver == receiver } end + # Filters call sites by method name. + # + # @param name [String] the method name to match + # @return [CallSiteCollection] def with_name(name) filter { |call_site| call_site.name == name } end + # Alias for {#with_name}. + # + # @param method_name [String] the method name to match + # @return [CallSiteCollection] def with_method_name(method_name) with_name(method_name) end + # Filters call sites that include the given symbol argument. + # + # @param symbol [Symbol] the symbol argument to match + # @return [CallSiteCollection] def with_symbol(symbol) filter { |call_site| call_site.symbols.include?(symbol) } end + # Filters call sites that include the given keyword argument. + # + # @param keyword_arg [Symbol] the keyword argument to match + # @return [CallSiteCollection] def with_keyword_arg(keyword_arg) filter { |call_site| call_site.keyword_args.include?(keyword_arg) } end diff --git a/lib/rubyzen/collections/classes_collection.rb b/lib/rubyzen/collections/classes_collection.rb index 5a62936..761e5b7 100644 --- a/lib/rubyzen/collections/classes_collection.rb +++ b/lib/rubyzen/collections/classes_collection.rb @@ -1,41 +1,73 @@ module Rubyzen module Collections + # Collection of class declarations with methods for navigating into + # child elements (methods, attributes, macros) and filtering by inheritance. + # + # @example Ensuring controllers inherit from ApplicationController + # expect(controllers.with_parent_prefix('ApplicationController')).not_to be_empty class ClassesCollection < BaseCollection include Rubyzen::Providers::CollectionFilterProvider + # Returns all instance and class methods across every class in the collection. + # + # @return [MethodsCollection] def all_methods instance_plus_class_methods = flat_map { |klass| klass.instance_methods + klass.class_methods } MethodsCollection.new(instance_plus_class_methods) end + # Returns all attribute declarations across every class. + # + # @return [AttributesCollection] def attributes all_attributes = flat_map(&:attributes) AttributesCollection.new(all_attributes) end + # Returns all macro invocations across every class. + # + # @return [MacrosCollection] def macros all_macros = flat_map(&:macros) MacrosCollection.new(all_macros) end + # Returns all rescue declarations across every class. + # + # @return [RescuesCollection] def rescues all_rescues = flat_map(&:rescues) RescuesCollection.new(all_rescues) end + # Returns all raise declarations across every class. + # + # @return [RaisesCollection] def raises all_raises = flat_map(&:raises) RaisesCollection.new(all_raises) end + # Filters classes whose superclass starts with the given prefix. + # + # @param prefix [String] the superclass name prefix to match + # @return [ClassesCollection] def with_parent_prefix(prefix) filter { |klass| klass.superclass_prefix?(prefix) } end + # Filters classes that contain at least one macro with the given name. + # + # @param macro_name [String] the macro name to search for + # @return [ClassesCollection] def with_macro_name(macro_name) filter { |klass| klass.macros.with_name(macro_name).any? } end + # Merges two ClassesCollections into a new ClassesCollection. + # + # @param other [ClassesCollection] the collection to merge + # @return [ClassesCollection] def +(other) merged = super(other) self.class.new(merged) diff --git a/lib/rubyzen/collections/constants_collection.rb b/lib/rubyzen/collections/constants_collection.rb index e2a226d..e158fe2 100644 --- a/lib/rubyzen/collections/constants_collection.rb +++ b/lib/rubyzen/collections/constants_collection.rb @@ -3,6 +3,10 @@ module Rubyzen module Collections + # Collection of constant declarations found in files, classes, or modules. + # + # @example Filtering constants by name + # project.files.constants.with_name('VERSION') class ConstantsCollection < BaseCollection include Rubyzen::Providers::CollectionFilterProvider end diff --git a/lib/rubyzen/collections/declaration_collection.rb b/lib/rubyzen/collections/declaration_collection.rb index 5aad7e6..66e48b4 100644 --- a/lib/rubyzen/collections/declaration_collection.rb +++ b/lib/rubyzen/collections/declaration_collection.rb @@ -1,5 +1,10 @@ module Rubyzen module Collections + # Generic collection for declaration objects that do not require + # specialized filtering methods (e.g., if statements). + # + # @example Collecting if-statement declarations from methods + # project.files.classes.all_methods.if_statements class DeclarationCollection < BaseCollection end end diff --git a/lib/rubyzen/collections/file_collection.rb b/lib/rubyzen/collections/file_collection.rb index c6dec51..7021fd3 100644 --- a/lib/rubyzen/collections/file_collection.rb +++ b/lib/rubyzen/collections/file_collection.rb @@ -1,46 +1,77 @@ module Rubyzen module Collections + # Collection of parsed file declarations. Serves as the top-level entry point + # for navigating into classes, modules, constants, and other code elements. + # + # @example Getting all controller classes + # project.files.with_paths('src/controllers/').classes class FileCollection < BaseCollection include Rubyzen::Providers::CollectionFilterProvider + # Filters files whose path includes any of the given substrings. + # + # @param paths [Array] path substrings to match + # @return [FileCollection] def with_paths(*paths) filter do |file_declaration| paths.any? { |p| file_declaration.path.include?(p) } end end + # Excludes files whose path includes any of the given substrings. + # + # @param paths [Array] path substrings to exclude + # @return [FileCollection] def without_paths(*paths) filter do |file_declaration| !paths.any? { |p| file_declaration.path.include?(p) } end end + # Returns all class declarations across every file. + # + # @return [ClassesCollection] def classes all_classes = flat_map(&:classes) ClassesCollection.new(all_classes) end + # Returns all module declarations across every file. + # + # @return [ModulesCollection] def modules all_modules = flat_map(&:modules) ModulesCollection.new(all_modules) end + # Returns all constant declarations across every file. + # + # @return [ConstantsCollection] def constants all_constants = flat_map(&:constants) ConstantsCollection.new(all_constants) end + # Returns all require/require_relative/load statements across every file. + # + # @return [RequiresCollection] def requires all_requires = flat_map(&:requires) RequiresCollection.new(all_requires) end + # Returns all call sites across every file. + # + # @return [CallSiteCollection] def call_sites all_call_sites = flat_map(&:call_sites) CallSiteCollection.new(all_call_sites) end + # Returns all block declarations across every file. + # + # @return [BlocksCollection] def blocks all_blocks = flat_map(&:blocks) BlocksCollection.new(all_blocks) diff --git a/lib/rubyzen/collections/macros_collection.rb b/lib/rubyzen/collections/macros_collection.rb index c59d4e0..32149d9 100644 --- a/lib/rubyzen/collections/macros_collection.rb +++ b/lib/rubyzen/collections/macros_collection.rb @@ -1,5 +1,9 @@ module Rubyzen module Collections + # Collection of class-level macro invocations (e.g., +validates+, +has_many+, +before_action+). + # + # @example Filtering macros by name + # controllers.macros.with_name('before_action') class MacrosCollection < BaseCollection include Rubyzen::Providers::CollectionFilterProvider end diff --git a/lib/rubyzen/collections/methods_collection.rb b/lib/rubyzen/collections/methods_collection.rb index d75d700..d018115 100644 --- a/lib/rubyzen/collections/methods_collection.rb +++ b/lib/rubyzen/collections/methods_collection.rb @@ -3,9 +3,17 @@ module Rubyzen module Collections + # Collection of method declarations with access to parameters, + # call sites, if statements, rescues, and raises within each method. + # + # @example Ensuring no method has more than 5 parameters + # controllers.all_methods.each { |m| expect(m.parameters.size).to be <= 5 } class MethodsCollection < BaseCollection include Rubyzen::Providers::CollectionFilterProvider + # Returns all parameters across every method. + # + # @return [ParametersCollection] def parameters ParametersCollection.new( flat_map do |method| @@ -14,6 +22,9 @@ def parameters ) end + # Returns all if-statement declarations across every method. + # + # @return [DeclarationCollection] def if_statements DeclarationCollection.new( flat_map do |method| @@ -22,6 +33,9 @@ def if_statements ) end + # Returns all call sites across every method. + # + # @return [CallSiteCollection] def call_sites CallSiteCollection.new( flat_map do |method| @@ -30,6 +44,9 @@ def call_sites ) end + # Returns all rescue declarations across every method. + # + # @return [RescuesCollection] def rescues RescuesCollection.new( flat_map do |method| @@ -38,6 +55,9 @@ def rescues ) end + # Returns all raise declarations across every method. + # + # @return [RaisesCollection] def raises RaisesCollection.new( flat_map do |method| diff --git a/lib/rubyzen/collections/modules_collection.rb b/lib/rubyzen/collections/modules_collection.rb index c79a5da..84f79b2 100644 --- a/lib/rubyzen/collections/modules_collection.rb +++ b/lib/rubyzen/collections/modules_collection.rb @@ -3,19 +3,33 @@ module Rubyzen module Collections + # Collection of module declarations with methods for navigating into + # child elements (methods, classes, constants). + # + # @example Getting all methods defined in modules + # project.files.modules.all_methods class ModulesCollection < BaseCollection include Rubyzen::Providers::CollectionFilterProvider + # Returns all methods defined across every module. + # + # @return [MethodsCollection] def all_methods all_methods = flat_map(&:all_methods) MethodsCollection.new(all_methods) end + # Returns all class declarations nested inside every module. + # + # @return [ClassesCollection] def classes all_classes = flat_map(&:classes) ClassesCollection.new(all_classes) end + # Returns all constant declarations across every module. + # + # @return [ConstantsCollection] def constants all_constants = flat_map(&:constants) ConstantsCollection.new(all_constants) diff --git a/lib/rubyzen/collections/parameters_collection.rb b/lib/rubyzen/collections/parameters_collection.rb index 0094c20..1521bb8 100644 --- a/lib/rubyzen/collections/parameters_collection.rb +++ b/lib/rubyzen/collections/parameters_collection.rb @@ -1,5 +1,9 @@ module Rubyzen module Collections + # Collection of method parameter declarations. + # + # @example Filtering parameters by name + # controllers.all_methods.parameters.with_name('id') class ParametersCollection < BaseCollection include Rubyzen::Providers::CollectionFilterProvider end diff --git a/lib/rubyzen/collections/raises_collection.rb b/lib/rubyzen/collections/raises_collection.rb index 75a54e6..a9c4d3a 100644 --- a/lib/rubyzen/collections/raises_collection.rb +++ b/lib/rubyzen/collections/raises_collection.rb @@ -1,10 +1,21 @@ module Rubyzen module Collections + # Collection of raise declarations found in methods or classes. + # + # @example Ensuring no plain-string raises in controllers + # expect(controllers.raises.with_string).to be_empty class RaisesCollection < BaseCollection + # Filters raises that use a plain string message (not an exception class). + # + # @return [RaisesCollection] def with_string filter(&:with_string?) end + # Filters raises that include the given exception class. + # + # @param exception_class [String] the exception class name to match + # @return [RaisesCollection] def with_exception_type(exception_class) filter do |raise_declaration| raise_declaration.exception_types.include?(exception_class) diff --git a/lib/rubyzen/collections/requires_collection.rb b/lib/rubyzen/collections/requires_collection.rb index d7cb7ae..6592b0e 100644 --- a/lib/rubyzen/collections/requires_collection.rb +++ b/lib/rubyzen/collections/requires_collection.rb @@ -3,17 +3,30 @@ module Rubyzen module Collections + # Collection of require/require_relative/load statements found in files. + # + # @example Ensuring controllers do not use require_relative + # expect(controller_files.requires.require_relative_calls).to be_empty class RequiresCollection < BaseCollection include Rubyzen::Providers::CollectionFilterProvider + # Returns only +require+ calls. + # + # @return [RequiresCollection] def require_calls filter(&:require?) end + # Returns only +require_relative+ calls. + # + # @return [RequiresCollection] def require_relative_calls filter(&:require_relative?) end + # Returns only +load+ calls. + # + # @return [RequiresCollection] def load_calls filter(&:load?) end diff --git a/lib/rubyzen/collections/rescues_collection.rb b/lib/rubyzen/collections/rescues_collection.rb index dd6bf2c..70528d0 100644 --- a/lib/rubyzen/collections/rescues_collection.rb +++ b/lib/rubyzen/collections/rescues_collection.rb @@ -1,6 +1,14 @@ module Rubyzen module Collections + # Collection of rescue declarations found in methods or classes. + # + # @example Checking for StandardError rescues + # project.files.classes.all_methods.rescues.with_exception_type('StandardError') class RescuesCollection < BaseCollection + # Filters rescues that handle the given exception class. + # + # @param exception_class [String] the exception class name to match + # @return [RescuesCollection] def with_exception_type(exception_class) filter do |rescue_declaration| rescue_declaration.exception_types.include?(exception_class) diff --git a/lib/rubyzen/declarations/attribute_declaration.rb b/lib/rubyzen/declarations/attribute_declaration.rb index bd7c0dd..339d42e 100644 --- a/lib/rubyzen/declarations/attribute_declaration.rb +++ b/lib/rubyzen/declarations/attribute_declaration.rb @@ -5,36 +5,60 @@ module Rubyzen module Declarations + # Represents an +attr_reader+, +attr_writer+, or +attr_accessor+ declaration. + # + # @example + # attr = klass.attributes.first + # attr.name #=> "attr_reader" + # attr.symbols #=> ["name", "email"] + # attr.reader? #=> true + # attr.private? #=> false + # class AttributeDeclaration include Rubyzen::Providers::FilePathProvider include Rubyzen::Providers::ClassNameProvider include Rubyzen::Providers::LineNumberProvider include Rubyzen::Providers::VisibilityProvider - attr_reader :node, :parent_class + # @return [RuboCop::AST::Node] + attr_reader :node + + # @return [ClassDeclaration, ModuleDeclaration] + attr_reader :parent_class alias :parent :parent_class + # @param node [RuboCop::AST::Node] the AST node + # @param parent_class [ClassDeclaration, ModuleDeclaration] the parent declaration def initialize(node, parent_class) @node = node @parent_class = parent_class end + # Returns the attribute type name. + # + # @return [String] one of +"attr_reader"+, +"attr_writer"+, +"attr_accessor"+ def name node.method_name.to_s end + # Returns the declared symbol names. + # + # @return [Array] e.g. +["name", "email"]+ def symbols node.arguments.map { |arg| arg.value.to_s if arg.type == :sym }.compact end + # @return [Boolean] true for +attr_reader+ and +attr_accessor+ def reader? %w[attr_reader attr_accessor].include?(name) end + # @return [Boolean] true for +attr_writer+ and +attr_accessor+ def writer? %w[attr_writer attr_accessor].include?(name) end + # @return [Boolean] true only for +attr_accessor+ def accessor? name == 'attr_accessor' end diff --git a/lib/rubyzen/declarations/block_declaration.rb b/lib/rubyzen/declarations/block_declaration.rb index a3a9be0..0c71d4f 100644 --- a/lib/rubyzen/declarations/block_declaration.rb +++ b/lib/rubyzen/declarations/block_declaration.rb @@ -1,5 +1,13 @@ module Rubyzen module Declarations + # Represents a Ruby block (+do...end+ or +{ }+). + # + # @example + # block = method.blocks.first + # block.method_name #=> "each" + # block.call_sites #=> CallSiteCollection + # block.lines_of_code #=> 5 + # class BlockDeclaration include Rubyzen::Providers::FilePathProvider include Rubyzen::Providers::LineNumberProvider @@ -10,17 +18,29 @@ class BlockDeclaration include Rubyzen::Providers::SourceCodeProvider include Rubyzen::Providers::CallSiteProvider - attr_reader :node, :parent + # @return [RuboCop::AST::Node] + attr_reader :node + # @return [MethodDeclaration, FileDeclaration] + attr_reader :parent + + # @param node [RuboCop::AST::Node] the AST node + # @param parent [MethodDeclaration, FileDeclaration] the parent declaration def initialize(node, parent) @node = node @parent = parent end + # Returns the method name the block is passed to. Alias for {#method_name}. + # + # @return [String] def name method_name end + # Returns the method name the block is passed to. + # + # @return [String] e.g. +"each"+, +"map"+, +"let"+ def method_name node.method_name.to_s end diff --git a/lib/rubyzen/declarations/call_site_declaration.rb b/lib/rubyzen/declarations/call_site_declaration.rb index fa4876a..6fa1f80 100644 --- a/lib/rubyzen/declarations/call_site_declaration.rb +++ b/lib/rubyzen/declarations/call_site_declaration.rb @@ -1,31 +1,56 @@ - module Rubyzen module Declarations + # Represents a method call site (a +send+ node in the AST). + # + # @example + # call_site = method.call_sites.first + # call_site.method_name #=> "find" + # call_site.receiver #=> "User" + # call_site.keyword_args #=> [:id, :name] + # class CallSiteDeclaration include Rubyzen::Providers::FilePathProvider include Rubyzen::Providers::LineNumberProvider include Rubyzen::Providers::ClassNameProvider include Rubyzen::Providers::SourceCodeProvider - attr_reader :node, :parent + # @return [RuboCop::AST::Node] + attr_reader :node + + # @return [MethodDeclaration, BlockDeclaration, FileDeclaration] + attr_reader :parent + # @param node [RuboCop::AST::Node] the AST node + # @param parent [MethodDeclaration, BlockDeclaration, FileDeclaration] the parent declaration def initialize(node, parent) @node = node @parent = parent end + # Returns the called method name. Alias for {#method_name}. + # + # @return [String] def name method_name end + # Returns the constant name of the receiver, if any. + # + # @return [String, nil] e.g. +"User"+ for +User.find(1)+, +nil+ for +save+ def receiver node.receiver&.type == :const ? node.receiver.const_name : nil end + # Returns the called method name. + # + # @return [String] def method_name - node.method_name.to_s + node.method_name.to_s end + # Returns the keyword argument keys passed in the call. + # + # @return [Array] e.g. +[:level, :details]+ def keyword_args node.arguments.flat_map do |arg| next [] unless arg.hash_type? @@ -36,6 +61,9 @@ def keyword_args end.uniq end + # Returns a hash mapping keyword argument keys to their literal values. + # + # @return [Hash{Symbol => Object}] values are +nil+ for non-literal expressions def keyword_arg_value_pairs result = {} node.arguments.each do |arg| @@ -51,10 +79,16 @@ def keyword_arg_value_pairs result end + # Returns positional symbol arguments. + # + # @return [Array] e.g. +[:name, :email]+ def symbols node.arguments.select { |arg| arg.type == :sym }.map(&:value) end + # Returns positional string arguments. + # + # @return [Array] def strings node.arguments.select { |arg| arg.type == :str }.map(&:value) end diff --git a/lib/rubyzen/declarations/class_declaration.rb b/lib/rubyzen/declarations/class_declaration.rb index b18a52b..d230833 100644 --- a/lib/rubyzen/declarations/class_declaration.rb +++ b/lib/rubyzen/declarations/class_declaration.rb @@ -1,5 +1,14 @@ module Rubyzen module Declarations + # Represents a Ruby class definition. Provides access to methods, attributes, + # macros, and other class-level constructs. + # + # @example + # klass = file.classes.first + # klass.name #=> "Admin::UsersController" + # klass.superclass_name #=> "ApplicationController" + # klass.instance_methods #=> MethodsCollection + # class ClassDeclaration include Rubyzen::Providers::IfStatementsProvider include Rubyzen::Providers::BlocksProvider @@ -13,13 +22,22 @@ class ClassDeclaration include Rubyzen::Providers::RescuesProvider include Rubyzen::Providers::RaisesProvider - attr_reader :node, :file_declaration + # @return [RuboCop::AST::Node] the class AST node + attr_reader :node + # @return [FileDeclaration] the file this class belongs to + attr_reader :file_declaration + + # @param node [RuboCop::AST::Node] + # @param file_declaration [FileDeclaration] def initialize(node, file_declaration) @node = node @file_declaration = file_declaration end + # Returns the fully-qualified class name including parent modules. + # + # @return [String] e.g. +"Admin::UsersController"+ def name parent_module_names = [] current_node = node.parent @@ -34,10 +52,16 @@ def name [parent_module_names, name_without_modules].flatten.compact.join('::') end + # Returns the class name without module prefixes. + # + # @return [String] e.g. +"UsersController"+ def name_without_modules node.identifier&.const_name end + # Returns the superclass name, if any. + # + # @return [String, nil] e.g. +"ApplicationController"+ def superclass_name super_node = node.children[1] return nil unless super_node&.type == :const @@ -45,10 +69,17 @@ def superclass_name super_node.const_name end + # Checks whether the superclass name starts with the given prefix. + # + # @param prefix [String] + # @return [Boolean] def superclass_prefix?(prefix) superclass_name&.start_with?(prefix) end + # Returns instance methods defined directly in this class. + # + # @return [Collections::MethodsCollection] def instance_methods Collections::MethodsCollection.new( instance_method_nodes.map do |def_node| @@ -57,6 +88,9 @@ def instance_methods ) end + # Returns class methods (both +self.method+ and +class << self+ styles). + # + # @return [Collections::MethodsCollection] def class_methods Collections::MethodsCollection.new( class_method_nodes.map do |method_node| @@ -65,10 +99,16 @@ def class_methods ) end + # Returns unique method names called anywhere in this class. + # + # @return [Array] def called_method_names node.each_descendant(:send).map { |send_node| send_node.method_name.to_s }.uniq end + # Returns the top-level module name from the enclosing file. + # + # @return [String, nil] def top_level_module file_declaration.top_level_module_name end diff --git a/lib/rubyzen/declarations/constant_declaration.rb b/lib/rubyzen/declarations/constant_declaration.rb index c672fa5..f034c2e 100644 --- a/lib/rubyzen/declarations/constant_declaration.rb +++ b/lib/rubyzen/declarations/constant_declaration.rb @@ -5,19 +5,36 @@ module Rubyzen module Declarations + # Represents a constant assignment (+MAX = 100+) or reference (+MAX+). + # + # @example + # const = file.constants.filter(&:assignment?).first + # const.name #=> "MAX" + # const.value #=> 100 + # const.top_level? #=> true + # class ConstantDeclaration include Rubyzen::Providers::FilePathProvider include Rubyzen::Providers::LineNumberProvider include Rubyzen::Providers::ClassNameProvider include Rubyzen::Providers::SourceCodeProvider - attr_reader :node, :parent + # @return [RuboCop::AST::Node] + attr_reader :node + # @return [FileDeclaration, ClassDeclaration, ModuleDeclaration] + attr_reader :parent + + # @param node [RuboCop::AST::Node] the AST node + # @param parent [FileDeclaration, ClassDeclaration, ModuleDeclaration] the parent declaration def initialize(node, parent) @node = node @parent = parent end + # Returns the constant name. + # + # @return [String] def name case node.type when :casgn @@ -27,12 +44,15 @@ def name end end + # Returns the assigned value for constant assignments. + # + # @return [String, Integer, Float, Boolean, nil] def value return nil unless assignment? - + value_node = node.children[2] return nil unless value_node - + case value_node.type when :str value_node.str_content @@ -47,48 +67,94 @@ def value end end + # Returns whether this is a constant assignment (+:casgn+). + # + # @return [Boolean] def assignment? node.type == :casgn end + # Returns whether this is a constant reference (+:const+). + # + # @return [Boolean] def reference? node.type == :const end + # Returns whether this constant is defined at file scope (not inside a class or module). + # + # @return [Boolean] def top_level? return false unless parent.is_a?(Rubyzen::Declarations::FileDeclaration) - + current_node = node while current_node current_node = current_node.parent return false if current_node && (current_node.type == :class || current_node.type == :module) end - + true end + # Returns the enclosing {ClassDeclaration}, if any. + # + # @return [ClassDeclaration, nil] + def enclosing_class + find_enclosing_ast_node(:class) do |n| + Rubyzen::Declarations::ClassDeclaration.new(n, file_declaration) + end + end + + # Returns whether this constant is defined inside a class. + # + # @return [Boolean] def in_class? - find_parent_of_type(Rubyzen::Declarations::ClassDeclaration) + !enclosing_class.nil? end + # Returns the enclosing {ModuleDeclaration}, if any. + # + # @return [ModuleDeclaration, nil] + def enclosing_module + find_enclosing_ast_node(:module) do |n| + Rubyzen::Declarations::ModuleDeclaration.new(n, file_declaration) + end + end + + # Returns whether this constant is defined inside a module. + # + # @return [Boolean] def in_module? - find_parent_of_type(Rubyzen::Declarations::ModuleDeclaration) + !enclosing_module.nil? end + # Returns whether this constant is defined inside a class or module. + # + # @return [Boolean] def scoped? !top_level? end private - def find_parent_of_type(type) + def file_declaration current = parent while current - return current if current.is_a?(type) + return current if current.is_a?(Rubyzen::Declarations::FileDeclaration) + return current.file_declaration if current.respond_to?(:file_declaration) current = current.respond_to?(:parent) ? current.parent : nil end nil end + + def find_enclosing_ast_node(type) + current_node = node.parent + while current_node + return yield(current_node) if current_node.type == type + current_node = current_node.respond_to?(:parent) ? current_node.parent : nil + end + nil + end end end end diff --git a/lib/rubyzen/declarations/file_declaration.rb b/lib/rubyzen/declarations/file_declaration.rb index ac227f9..9718dfb 100644 --- a/lib/rubyzen/declarations/file_declaration.rb +++ b/lib/rubyzen/declarations/file_declaration.rb @@ -1,5 +1,14 @@ module Rubyzen module Declarations + # Represents a parsed Ruby source file. This is the root of the declaration + # hierarchy — all other declarations are accessed through a FileDeclaration. + # + # @example + # project = Rubyzen::Project.new("/app/src") + # file = project.files.first + # file.name #=> "user.rb" + # file.classes #=> [ClassDeclaration, ...] + # class FileDeclaration include Rubyzen::Providers::FilePathProvider include Rubyzen::Providers::LineNumberProvider @@ -9,28 +18,47 @@ class FileDeclaration include Rubyzen::Providers::CallSiteProvider include Rubyzen::Providers::BlocksProvider - attr_reader :path, :node + # @return [String] absolute path to the source file + attr_reader :path + + # @return [RuboCop::AST::Node] the root AST node + attr_reader :node alias :ast :node + # @param path [String] absolute file path + # @param ast [RuboCop::AST::Node] parsed AST root node def initialize(path, ast) @path = path @node = ast end + # Returns the basename of the file. + # + # @return [String] e.g. +"user.rb"+ def name File.basename(path) end + # Returns all classes defined in this file. + # + # @return [Array] def classes node.each_node(:class).map do |class_node| ClassDeclaration.new(class_node, self) end end + # Returns the name of the first module in the file, used to determine + # the top-level namespace. + # + # @return [String, nil] def top_level_module_name modules.first&.name_without_modules end + # Returns all modules defined in this file. + # + # @return [Array] def modules node.each_node(:module).map do |module_node| Rubyzen::Declarations::ModuleDeclaration.new(module_node, self) diff --git a/lib/rubyzen/declarations/if_statement_declaration.rb b/lib/rubyzen/declarations/if_statement_declaration.rb index 3f9f297..793f58d 100644 --- a/lib/rubyzen/declarations/if_statement_declaration.rb +++ b/lib/rubyzen/declarations/if_statement_declaration.rb @@ -1,22 +1,41 @@ module Rubyzen module Declarations + # Represents an +if+ / +unless+ statement within a method or class. + # + # @example + # if_stmt = method.if_statements.first + # if_stmt.condition_source #=> "user.active?" + # if_stmt.source_code #=> "if user.active?\n ..." + # class IfStatementDeclaration include Rubyzen::Providers::FilePathProvider include Rubyzen::Providers::ClassNameProvider include Rubyzen::Providers::LineNumberProvider include Rubyzen::Providers::SourceCodeProvider - attr_reader :node, :parent + # @return [RuboCop::AST::Node] + attr_reader :node + # @return [MethodDeclaration, ClassDeclaration] + attr_reader :parent + + # @param node [RuboCop::AST::Node] the AST node + # @param parent [MethodDeclaration, ClassDeclaration] the parent declaration def initialize(node, parent) @node = node @parent = parent end + # Returns the raw source of the condition expression. + # + # @return [String, nil] def condition_source node.condition&.source end + # Returns the name of the parent declaration. + # + # @return [String] def name parent.name end diff --git a/lib/rubyzen/declarations/macro_declaration.rb b/lib/rubyzen/declarations/macro_declaration.rb index a2c419b..d0a1a51 100644 --- a/lib/rubyzen/declarations/macro_declaration.rb +++ b/lib/rubyzen/declarations/macro_declaration.rb @@ -1,34 +1,63 @@ module Rubyzen module Declarations + # Represents a class-level macro call (e.g. +validates_required+, +belongs_to+). + # + # @example + # macro = klass.macros.first + # macro.name #=> "validates_required" + # macro.symbols #=> [:name, :email] + # macro.keyword_args #=> [:foreign_key, :optional] + # class MacroDeclaration include Rubyzen::Providers::FilePathProvider include Rubyzen::Providers::ClassNameProvider include Rubyzen::Providers::LineNumberProvider include Rubyzen::Providers::SourceCodeProvider - attr_reader :node, :parent + # @return [RuboCop::AST::Node] + attr_reader :node + # @return [ClassDeclaration] + attr_reader :parent + + # @param node [RuboCop::AST::Node] the AST node + # @param parent [ClassDeclaration] the parent declaration def initialize(node, parent) @node = node @parent = parent end + # Returns the macro method name. + # + # @return [String] e.g. +"validates_required"+, +"belongs_to"+ def name node.method_name.to_s end + # Returns positional symbol arguments. + # + # @return [Array] def symbols node.arguments.select { |arg| arg.type == :sym }.map(&:value) end + # Returns positional string arguments. + # + # @return [Array] def strings node.arguments.select { |arg| arg.type == :str }.map(&:value) end + # Returns keyword argument keys. + # + # @return [Array] def keyword_args extract_keyword_args(node) end + # Returns the constant receiver, if any. + # + # @return [String, nil] e.g. +"Config"+ for +Config.setting+ def receiver node.receiver&.type == :const ? node.receiver.const_name : nil end diff --git a/lib/rubyzen/declarations/method_declaration.rb b/lib/rubyzen/declarations/method_declaration.rb index 47ee1f8..370e395 100644 --- a/lib/rubyzen/declarations/method_declaration.rb +++ b/lib/rubyzen/declarations/method_declaration.rb @@ -1,5 +1,14 @@ module Rubyzen module Declarations + # Represents a Ruby method definition (+def+ or +def self.+). + # + # @example + # method = klass.instance_methods.first + # method.name #=> "calculate" + # method.parameters? #=> true + # method.call_sites #=> CallSiteCollection + # method.visibility #=> :private + # class MethodDeclaration include Rubyzen::Providers::IfStatementsProvider include Rubyzen::Providers::BlocksProvider @@ -13,7 +22,11 @@ class MethodDeclaration include Rubyzen::Providers::RescuesProvider include Rubyzen::Providers::RaisesProvider - attr_reader :node, :parent_class + # @return [RuboCop::AST::Node] + attr_reader :node + + # @return [ClassDeclaration, ModuleDeclaration] + attr_reader :parent_class alias :parent :parent_class def initialize(node, parent_class) @@ -21,10 +34,16 @@ def initialize(node, parent_class) @parent_class = parent_class end + # Returns the method name. + # + # @return [String] def name node.method_name.to_s end + # Returns the method's parameters. + # + # @return [Collections::ParametersCollection] def parameters Collections::ParametersCollection.new( node.arguments.map do |arg| @@ -33,6 +52,9 @@ def parameters ) end + # Returns whether this method has any parameters. + # + # @return [Boolean] def parameters? node.arguments.any? end diff --git a/lib/rubyzen/declarations/module_declaration.rb b/lib/rubyzen/declarations/module_declaration.rb index d42c03a..0e72d20 100644 --- a/lib/rubyzen/declarations/module_declaration.rb +++ b/lib/rubyzen/declarations/module_declaration.rb @@ -1,5 +1,13 @@ module Rubyzen module Declarations + # Represents a Ruby module definition. + # + # @example + # mod = file.modules.first + # mod.name #=> "Admin::Api" + # mod.all_methods #=> MethodsCollection + # mod.classes #=> [ClassDeclaration, ...] + # class ModuleDeclaration include Rubyzen::Providers::FilePathProvider include Rubyzen::Providers::LineNumberProvider @@ -8,13 +16,22 @@ class ModuleDeclaration include Rubyzen::Providers::LinesOfCodeProvider include Rubyzen::Providers::AttributesProvider - attr_reader :node, :file_declaration + # @return [RuboCop::AST::Node] + attr_reader :node + # @return [FileDeclaration] + attr_reader :file_declaration + + # @param node [RuboCop::AST::Node] the AST node + # @param file_declaration [FileDeclaration] the parent file declaration def initialize(node, file_declaration) @node = node @file_declaration = file_declaration end + # Returns the fully-qualified module name including parent modules. + # + # @return [String] e.g. +"Admin::Api"+ def name parent_module_names = [] current_node = node.parent @@ -29,20 +46,32 @@ def name [parent_module_names, name_without_modules].flatten.compact.join('::') end + # Returns the module name without parent module prefixes. + # + # @return [String] def name_without_modules node.identifier&.const_name end + # Returns nested modules within this module. + # + # @return [Array] def modules - node.each_node(:module).map { |mod_node| ModuleDeclaration.new(mod_node, file_declaration) } + node.each_descendant(:module).map { |mod_node| ModuleDeclaration.new(mod_node, file_declaration) } end + # Returns classes defined within this module. + # + # @return [Array] def classes node.each_node(:class).map do |class_node| ClassDeclaration.new(class_node, file_declaration) end end + # Returns methods defined directly in this module. + # + # @return [Collections::MethodsCollection] def all_methods Collections::MethodsCollection.new( direct_method_nodes.map { |method_node| MethodDeclaration.new(method_node, self) } diff --git a/lib/rubyzen/declarations/parameter_declaration.rb b/lib/rubyzen/declarations/parameter_declaration.rb index b28d295..91aa378 100644 --- a/lib/rubyzen/declarations/parameter_declaration.rb +++ b/lib/rubyzen/declarations/parameter_declaration.rb @@ -1,24 +1,43 @@ module Rubyzen module Declarations + # Represents a method parameter. + # + # @example + # param = method.parameters.first + # param.name #=> :user_id + # param.default_value #=> 42 + # class ParameterDeclaration include Rubyzen::Providers::FilePathProvider include Rubyzen::Providers::LineNumberProvider include Rubyzen::Providers::ClassNameProvider - attr_reader :node, :parent + # @return [RuboCop::AST::Node] + attr_reader :node + # @return [MethodDeclaration] + attr_reader :parent + + # @param node [RuboCop::AST::Node] the AST node + # @param parent [MethodDeclaration] the parent declaration def initialize(node, parent) @node = node @parent = parent end + # Returns the parameter name. + # + # @return [Symbol] def name node.name end + # Returns the default value if one is defined. + # + # @return [Object, nil] def default_value node.children[1]&.value end end end -end \ No newline at end of file +end diff --git a/lib/rubyzen/declarations/raise_declaration.rb b/lib/rubyzen/declarations/raise_declaration.rb index ceb7f57..e1691cf 100644 --- a/lib/rubyzen/declarations/raise_declaration.rb +++ b/lib/rubyzen/declarations/raise_declaration.rb @@ -1,27 +1,51 @@ module Rubyzen module Declarations + # Represents a +raise+ statement. + # + # @example + # raise_decl = method.raises.first + # raise_decl.exception_types #=> ["ArgumentError"] + # raise_decl.message #=> "invalid input" + # raise_decl.with_string? #=> false + # class RaiseDeclaration include Rubyzen::Providers::FilePathProvider include Rubyzen::Providers::LineNumberProvider include Rubyzen::Providers::ClassNameProvider include Rubyzen::Providers::SourceCodeProvider - attr_reader :node, :parent + # @return [RuboCop::AST::Node] + attr_reader :node + # @return [MethodDeclaration, BlockDeclaration] + attr_reader :parent + + # @param node [RuboCop::AST::Node] the AST node + # @param parent [MethodDeclaration, BlockDeclaration] the parent declaration def initialize(node, parent) @node = node @parent = parent end + # Returns the exception class names being raised. + # Defaults to +["RuntimeError"]+ for bare +raise+ or +raise "message"+. + # + # @return [Array] def exception_types extract_exception_types end + # Returns whether the raise uses a bare string (+raise "message"+). + # + # @return [Boolean] def with_string? first_arg = node.arguments.first first_arg&.type == :str end + # Returns the error message string, if any. + # + # @return [String, nil] def message extract_message end diff --git a/lib/rubyzen/declarations/require_declaration.rb b/lib/rubyzen/declarations/require_declaration.rb index 2c5788b..5735673 100644 --- a/lib/rubyzen/declarations/require_declaration.rb +++ b/lib/rubyzen/declarations/require_declaration.rb @@ -3,36 +3,59 @@ module Rubyzen module Declarations + # Represents a +require+, +require_relative+, or +load+ statement. + # + # @example + # req = file.requires.first + # req.required_path #=> "json" + # req.require? #=> true + # req.require_relative? #=> false + # class RequireDeclaration include Rubyzen::Providers::FilePathProvider include Rubyzen::Providers::LineNumberProvider - attr_reader :node, :parent_file + # @return [RuboCop::AST::Node] + attr_reader :node + + # @return [FileDeclaration] + attr_reader :parent_file alias :parent :parent_file + # @param node [RuboCop::AST::Node] the AST node + # @param parent_file [FileDeclaration] the parent file declaration def initialize(node, parent_file) @node = node @parent_file = parent_file end + # Returns the statement type. + # + # @return [String] one of +"require"+, +"require_relative"+, +"load"+ def name node.method_name.to_s end + # Returns the required path string. + # + # @return [String, nil] def required_path first_arg = node.arguments.first return nil unless first_arg&.type == :str first_arg.value end + # @return [Boolean] def require? name == 'require' end + # @return [Boolean] def require_relative? name == 'require_relative' end + # @return [Boolean] def load? name == 'load' end diff --git a/lib/rubyzen/declarations/rescue_declaration.rb b/lib/rubyzen/declarations/rescue_declaration.rb index 6a8f571..81bb2e1 100644 --- a/lib/rubyzen/declarations/rescue_declaration.rb +++ b/lib/rubyzen/declarations/rescue_declaration.rb @@ -1,17 +1,33 @@ module Rubyzen module Declarations + # Represents a +rescue+ clause within a method or block. + # + # @example + # rescue_decl = method.rescues.first + # rescue_decl.exception_types #=> ["ArgumentError", "TypeError"] + # class RescueDeclaration include Rubyzen::Providers::FilePathProvider include Rubyzen::Providers::LineNumberProvider include Rubyzen::Providers::ClassNameProvider - attr_reader :node, :parent + # @return [RuboCop::AST::Node] + attr_reader :node + # @return [MethodDeclaration, BlockDeclaration] + attr_reader :parent + + # @param node [RuboCop::AST::Node] the AST node + # @param parent [MethodDeclaration, BlockDeclaration] the parent declaration def initialize(node, parent) @node = node @parent = parent end + # Returns the rescued exception class names. + # Defaults to +["StandardError"]+ for bare +rescue+. + # + # @return [Array] def exception_types extract_exception_types end diff --git a/lib/rubyzen/matchers/be_empty_matcher.rb b/lib/rubyzen/matchers/be_empty_matcher.rb index 97a866b..0b556d5 100644 --- a/lib/rubyzen/matchers/be_empty_matcher.rb +++ b/lib/rubyzen/matchers/be_empty_matcher.rb @@ -1,5 +1,13 @@ - - +# Custom RSpec matcher that asserts a Rubyzen collection is empty. +# +# Used in architectural lint rules to verify that no items match +# a forbidden pattern (e.g., no controllers call +.where+ directly). +# +# @example Ensure no controllers use .where +# expect(controllers.that { have_call_sites_with_names('.where') }).to be_empty +# +# @example With a custom failure message +# expect(violations).to be_empty("Controllers should not call .where directly") RSpec::Matchers.define :be_empty do |custom_message=nil, allowlist: nil, baseline: nil| include Rubyzen::Matchers::MatcherHelpers diff --git a/lib/rubyzen/matchers/be_empty_with_exceptions_matcher.rb b/lib/rubyzen/matchers/be_empty_with_exceptions_matcher.rb index 3ab5a44..d03f471 100644 --- a/lib/rubyzen/matchers/be_empty_with_exceptions_matcher.rb +++ b/lib/rubyzen/matchers/be_empty_with_exceptions_matcher.rb @@ -1,14 +1,27 @@ +# @deprecated Use {be_empty} with +allowlist:+ and +baseline:+ keyword arguments instead. +# The +be_empty+ matcher now supports all the same exception handling capabilities. +# +# Custom RSpec matcher that delegates to +be_empty+ with exception support. +# Kept for backwards compatibility only. +# +# @example Migrating to be_empty +# # Before (deprecated): +# expect(violations).to be_empty_with_exceptions(allowlist: ['LegacyController']) +# +# # After (preferred): +# expect(violations).to be_empty(allowlist: ['LegacyController']) RSpec::Matchers.define :be_empty_with_exceptions do |custom_message=nil, allowlist: nil, baseline: nil| - match do |subject_collection| - @matcher = be_empty(custom_message, allowlist: allowlist, baseline: baseline) - @matcher.matches?(subject_collection) - end + match do |subject_collection| + warn "[DEPRECATION] `be_empty_with_exceptions` is deprecated. Use `be_empty(allowlist:, baseline:)` instead." + @matcher = be_empty(custom_message, allowlist: allowlist, baseline: baseline) + @matcher.matches?(subject_collection) + end - failure_message do |_| - @matcher.failure_message - end + failure_message do |_| + @matcher.failure_message + end - failure_message_when_negated do |_| - @matcher.failure_message_when_negated - end + failure_message_when_negated do |_| + @matcher.failure_message_when_negated + end end diff --git a/lib/rubyzen/matchers/be_false_matcher.rb b/lib/rubyzen/matchers/be_false_matcher.rb index 4317c9c..e10dca8 100644 --- a/lib/rubyzen/matchers/be_false_matcher.rb +++ b/lib/rubyzen/matchers/be_false_matcher.rb @@ -1,30 +1,63 @@ - -RSpec::Matchers.define :be_false do |custom_message=nil| +# Custom RSpec matcher that asserts a block returns false for every item in a collection. +# +# Supports +allowlist:+ and +baseline:+ for gradual adoption, matching items +# where the block returns true against exception lists. +# +# @example Ensure no methods have more than 5 parameters +# expect(methods).to be_false { |m| m.parameters.size > 5 } +# +# @example With a custom failure message +# expect(controllers.all_methods.call_sites).to be_false("Controllers must not call .where directly") { |cs| cs.name == 'where' } +# +# @example With a baseline for gradual adoption +# expect(classes).to be_false(baseline: ['LegacyModel']) { |k| k.lines_of_code > 200 } +RSpec::Matchers.define :be_false do |custom_message=nil, allowlist: nil, baseline: nil| include Rubyzen::Matchers::MatcherHelpers match do |subject_collection| - @custom_message = custom_message + options = custom_message.is_a?(Hash) ? custom_message : {} + resolved_allowlist = allowlist || options[:allowlist] || options['allowlist'] + resolved_baseline = baseline || options[:baseline] || options['baseline'] + @custom_message = options[:message] || options['message'] || (custom_message unless custom_message.is_a?(Hash)) @offenders = [] if block_arg != nil - items = Array(subject_collection) # to handle one or multiple subjects + items = Array(subject_collection) + + failing_items = items.filter { |item| block_arg.call(item) } + @classified_items = classify_items( + failing_items, + allowlist: resolved_allowlist, + baseline: resolved_baseline + ) + @offenders = @classified_items[:violations] + + stale_exception_groups = [] + stale_baseline = @classified_items[:stale_baseline] + stale_allowlist = @classified_items[:stale_allowlist] + stale_exception_groups << 'baseline entries' if stale_baseline.any? + stale_exception_groups << 'allowlist entries' if stale_allowlist.any? - items.each do |item| - @offenders << element_name(item) unless !block_arg.call(item) - end + @failure_reason = if @offenders.any? && stale_exception_groups.any? + "Expected to return false for all elements, but found live violations and stale #{stale_exception_groups.join(' and ')}." + elsif @offenders.any? + "Expected to return false for all elements." + elsif stale_exception_groups.any? + "Expected to return false for all elements, but found stale #{stale_exception_groups.join(' and ')}." + end - @offenders.empty? + @offenders.empty? && stale_baseline.empty? && stale_allowlist.empty? else - @failure_message = "Expected a block, but got nil." + @failure_reason = "Expected a block, but got nil." false end end failure_message do |_| - message_for_failure("Expected to return false for all elements, but returned true for:\n#{@offenders.join("\n")}") + message_for_failure(@failure_reason || "Expected to return false for all elements.") end failure_message_when_negated do |_| - message_for_failure("Expected to return true for at least one element, but returned false for:\n#{@offenders.join("\n")}") + message_for_failure("Expected to return true for at least one element, but all elements returned false.") end end diff --git a/lib/rubyzen/matchers/be_true_matcher.rb b/lib/rubyzen/matchers/be_true_matcher.rb index bd35cd6..76a87cf 100644 --- a/lib/rubyzen/matchers/be_true_matcher.rb +++ b/lib/rubyzen/matchers/be_true_matcher.rb @@ -1,5 +1,10 @@ - - +# Custom RSpec matcher that asserts a block returns true for every item in a collection. +# +# @example Ensure all methods have parameters +# expect(methods).to be_true { |m| m.parameters? } +# +# @example With a custom failure message +# expect(services).to be_true("All services must inherit from BaseService") { |s| s.superclass_name == 'BaseService' } RSpec::Matchers.define :be_true do |custom_message=nil, allowlist: nil, baseline: nil| include Rubyzen::Matchers::MatcherHelpers @@ -30,7 +35,7 @@ @failure_reason = if @offenders.any? && stale_exception_groups.any? "Expected to return true for all elements, but found live violations and stale #{stale_exception_groups.join(' and ')}." elsif @offenders.any? - "Expected to return true for all elements, but returned false for:\n#{@offenders.join("\n")}" + "Expected to return true for all elements." elsif stale_exception_groups.any? "Expected to return true for all elements, but found stale #{stale_exception_groups.join(' and ')}." end @@ -47,6 +52,6 @@ end failure_message_when_negated do |_| - message_for_failure("Expected to return false for at least one element, but returned true for:\n#{Array(@classified_items&.dig(:baseline)).join("\n")}") + message_for_failure("Expected to return false for at least one element, but all elements returned true.") end end diff --git a/lib/rubyzen/matchers/matcher_helpers.rb b/lib/rubyzen/matchers/matcher_helpers.rb index 6d74d5e..0c17b9c 100644 --- a/lib/rubyzen/matchers/matcher_helpers.rb +++ b/lib/rubyzen/matchers/matcher_helpers.rb @@ -1,10 +1,23 @@ module Rubyzen module Matchers + # Shared helper methods used by Rubyzen's custom RSpec matchers. + # + # Provides utilities for normalizing exception lists, extracting item + # details, matching items against allowlist/baseline entries, and + # formatting failure messages. module MatcherHelpers + # Normalizes a list of exception entries into unique, non-blank strings. + # + # @param entries [Array, String, nil] raw exception entries + # @return [Array] deduplicated, stripped, non-empty strings def normalize_exception_entries(entries) Array(entries).flatten.compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq end + # Extracts identifying details from a declaration item. + # + # @param item [Object] a declaration object (e.g., FileDeclaration, ClassDeclaration) + # @return [Hash{Symbol => String, nil}] hash with :name, :class_name, :file_path, :line def item_details(item) { name: item.respond_to?(:name) ? item.name : nil, @@ -14,6 +27,10 @@ def item_details(item) } end + # Returns a list of unique identifier strings for an item, used for matching. + # + # @param item [Object] a declaration object + # @return [Array] identifiers such as name, class name, file path, and file:line def item_identifiers(item) details = item_details(item) identifiers = [details[:name], details[:class_name], details[:file_path]] @@ -25,6 +42,11 @@ def item_identifiers(item) identifiers.compact.uniq end + # Checks whether a given exception entry string matches an item. + # + # @param entry [String] an allowlist or baseline entry + # @param item [Object] a declaration object + # @return [Boolean] true if the entry matches the item by name, class, or path def exception_entry_matches_item?(entry, item) normalized_entry = entry.to_s.strip return false if normalized_entry.empty? @@ -36,6 +58,14 @@ def exception_entry_matches_item?(entry, item) file_path && (file_path.end_with?(normalized_entry) || file_path.end_with?("/#{normalized_entry}")) end + # Classifies items into violations, baseline matches, allowlist matches, + # and detects stale entries in either list. + # + # @param subject_collection [Array, Object] items to classify + # @param allowlist [Array, nil] allowed exception entries + # @param baseline [Array, nil] baseline exception entries + # @return [Hash{Symbol => Array}] keys: :violations, :baseline, :allowlist, + # :stale_baseline, :stale_allowlist def classify_items(subject_collection, allowlist: nil, baseline: nil) items = Array(subject_collection).compact normalized_allowlist = normalize_exception_entries(allowlist) @@ -77,6 +107,10 @@ def classify_items(subject_collection, allowlist: nil, baseline: nil) ) end + # Formats a human-readable description of an item for failure messages. + # + # @param item [Object] a declaration object + # @return [String] formatted multi-line description def element_name(item) details = item_details(item) location = [details[:file_path], details[:line]].compact.join(':') @@ -93,6 +127,9 @@ def element_name(item) end end + # Builds a formatted string of violations and stale entries for failure output. + # + # @return [String, nil] formatted sections or nil if no classified items def formatted_matcher_groups return unless defined?(@classified_items) && @classified_items diff --git a/lib/rubyzen/parsers/a_s_t_parser.rb b/lib/rubyzen/parsers/a_s_t_parser.rb index 4b59491..1b31463 100644 --- a/lib/rubyzen/parsers/a_s_t_parser.rb +++ b/lib/rubyzen/parsers/a_s_t_parser.rb @@ -2,7 +2,12 @@ module Rubyzen module Parsers + # Singleton parser that converts Ruby source files into Rubyzen declarations + # using RuboCop's AST processing. Results are cached via {Cache::ParseCache}. class ASTParser + # Returns the singleton instance of the parser. + # + # @return [ASTParser] def self.instance @instance ||= new end @@ -11,6 +16,10 @@ def initialize @cache = Rubyzen::Cache::ParseCache.new end + # Parses a Ruby source file and returns its declaration, using the cache. + # + # @param file_path [String] absolute path to the Ruby file + # @return [Declarations::FileDeclaration, nil] the parsed file declaration, or nil if unparseable def parse_file(file_path) @cache.fetch_or_parse(file_path) do source = File.read(file_path) diff --git a/lib/rubyzen/project.rb b/lib/rubyzen/project.rb index 79da7f5..f10332a 100644 --- a/lib/rubyzen/project.rb +++ b/lib/rubyzen/project.rb @@ -1,6 +1,19 @@ - module Rubyzen + # Main entry point for analyzing a Ruby project. Parses all +.rb+ files + # in the given paths and provides access to files, classes, and modules. + # + # @example Analyzing specific directories + # project = Rubyzen::Project.new(["/app/src", "/app/spec"]) + # project.files.with_paths("controllers/").classes + # + # @example Using environment variable + # # With RUBYZEN_PROJECT_PATHS set + # project = Rubyzen::Project.new + # project.classes.with_name("UsersController") + # class Project + # @param paths [String, Array, nil] directories or file paths to analyze. + # Falls back to {Configuration#project_paths} from +RUBYZEN_PROJECT_PATHS+ env var if nil. def initialize(paths = nil) paths ||= Rubyzen.configuration.project_paths @root_paths = Array(paths) @@ -16,16 +29,25 @@ def initialize(paths = nil) @parser = Rubyzen::Parsers::ASTParser.instance end + # Returns all parsed files as a filterable collection. + # + # @return [Collections::FileCollection] def files all_files = file_declarations Collections::FileCollection.new(all_files) end + # Returns all classes found across all parsed files. + # + # @return [Collections::ClassesCollection] def classes all_classes = file_declarations.flat_map(&:classes) Collections::ClassesCollection.new(all_classes) end + # Returns all modules found across all parsed files. + # + # @return [Collections::ModulesCollection] def modules all_modules = file_declarations.flat_map(&:modules) Collections::ModulesCollection.new(all_modules) diff --git a/lib/rubyzen/providers/attributes_provider.rb b/lib/rubyzen/providers/attributes_provider.rb index 36ab99a..3fb7df7 100644 --- a/lib/rubyzen/providers/attributes_provider.rb +++ b/lib/rubyzen/providers/attributes_provider.rb @@ -3,7 +3,9 @@ module Rubyzen module Providers + # Provides access to attr_reader, attr_writer, and attr_accessor declarations. module AttributesProvider + # @return [Rubyzen::Collections::AttributesCollection] collection of attribute declarations def attributes attribute_nodes = node.each_descendant(:send).select do |send_node| %w[attr_reader attr_writer attr_accessor].include?(send_node.method_name.to_s) diff --git a/lib/rubyzen/providers/blocks_provider.rb b/lib/rubyzen/providers/blocks_provider.rb index 5e0a3c7..4a77981 100644 --- a/lib/rubyzen/providers/blocks_provider.rb +++ b/lib/rubyzen/providers/blocks_provider.rb @@ -1,6 +1,8 @@ module Rubyzen module Providers + # Provides access to block expressions (do..end / {..}) within a declaration. module BlocksProvider + # @return [Rubyzen::Collections::BlocksCollection] collection of block declarations def blocks Collections::BlocksCollection.new( node.each_descendant(:block).map do |block_node| diff --git a/lib/rubyzen/providers/call_site_provider.rb b/lib/rubyzen/providers/call_site_provider.rb index fe201a2..f4cc39f 100644 --- a/lib/rubyzen/providers/call_site_provider.rb +++ b/lib/rubyzen/providers/call_site_provider.rb @@ -1,6 +1,8 @@ module Rubyzen module Providers + # Provides access to method call sites within a declaration. module CallSiteProvider + # @return [Rubyzen::Collections::CallSiteCollection] collection of call site declarations def call_sites Collections::CallSiteCollection.new( node.each_descendant(:send).map do |send_node| diff --git a/lib/rubyzen/providers/class_name_provider.rb b/lib/rubyzen/providers/class_name_provider.rb index c1bfc02..fb52674 100644 --- a/lib/rubyzen/providers/class_name_provider.rb +++ b/lib/rubyzen/providers/class_name_provider.rb @@ -1,6 +1,8 @@ module Rubyzen module Providers + # Provides access to the enclosing class name by traversing parent declarations. module ClassNameProvider + # @return [String, nil] the name of the enclosing class, or nil if not within a class def class_name class_name_recursive(self) end diff --git a/lib/rubyzen/providers/collection_filter_provider.rb b/lib/rubyzen/providers/collection_filter_provider.rb index 0a79a79..ff3cc65 100644 --- a/lib/rubyzen/providers/collection_filter_provider.rb +++ b/lib/rubyzen/providers/collection_filter_provider.rb @@ -1,18 +1,28 @@ module Rubyzen module Providers + # Provides name-based filtering methods for collections. module CollectionFilterProvider + # @param name [String] the exact name to match + # @return [self] filtered collection containing only items with the given name def with_name(name) filter { |item| item.name == name } end + # @param suffix [String] the suffix to match against item names + # @return [self] filtered collection of items whose names end with the suffix def with_name_ending_with(suffix) filter { |item| item.name&.end_with?(suffix) } end + # @param prefix [String] the prefix to match against item names + # @return [self] filtered collection of items whose names start with the prefix def with_name_starting_with(prefix) filter { |item| item.name&.start_with?(prefix) } end + # @param substring [String] the substring to search for in item names + # @param case_sensitive [Boolean] whether the match is case-sensitive (default: true) + # @return [self] filtered collection of items whose names contain the substring def with_name_including(substring, case_sensitive: true) if case_sensitive filter { |item| item.name&.include?(substring) } @@ -21,18 +31,27 @@ def with_name_including(substring, case_sensitive: true) end end + # @param names [Array] names to exclude + # @return [self] filtered collection excluding items with any of the given names def without_name(*names) filter { |item| !names.include?(item.name) } end + # @param suffix [String] the suffix to exclude + # @return [self] filtered collection excluding items whose names end with the suffix def without_name_ending_with(suffix) filter { |item| !item.name&.end_with?(suffix) } end + # @param prefix [String] the prefix to exclude + # @return [self] filtered collection excluding items whose names start with the prefix def without_name_starting_with(prefix) filter { |item| !item.name&.start_with?(prefix) } end + # @param substring [String] the substring to exclude + # @param case_sensitive [Boolean] whether the match is case-sensitive (default: true) + # @return [self] filtered collection excluding items whose names contain the substring def without_name_including(substring, case_sensitive: true) if case_sensitive filter { |item| !item.name&.include?(substring) } diff --git a/lib/rubyzen/providers/constants_provider.rb b/lib/rubyzen/providers/constants_provider.rb index c065b8c..b8806ed 100644 --- a/lib/rubyzen/providers/constants_provider.rb +++ b/lib/rubyzen/providers/constants_provider.rb @@ -1,6 +1,8 @@ module Rubyzen module Providers + # Provides access to constant references and assignments within a declaration. module ConstantsProvider + # @return [Rubyzen::Collections::ConstantsCollection] collection of constant declarations def constants constant_nodes = node.each_descendant(:casgn, :const) diff --git a/lib/rubyzen/providers/file_path_provider.rb b/lib/rubyzen/providers/file_path_provider.rb index dc853a3..cfdb01f 100644 --- a/lib/rubyzen/providers/file_path_provider.rb +++ b/lib/rubyzen/providers/file_path_provider.rb @@ -1,6 +1,8 @@ module Rubyzen module Providers + # Provides access to the file path of a declaration by traversing parent declarations. module FilePathProvider + # @return [String, nil] the file path containing this declaration def file_path file_path_recursive(self) end diff --git a/lib/rubyzen/providers/if_statements_provider.rb b/lib/rubyzen/providers/if_statements_provider.rb index f098383..7de843e 100644 --- a/lib/rubyzen/providers/if_statements_provider.rb +++ b/lib/rubyzen/providers/if_statements_provider.rb @@ -1,6 +1,8 @@ module Rubyzen module Providers + # Provides access to if/unless conditional statements within a declaration. module IfStatementsProvider + # @return [Rubyzen::Collections::DeclarationCollection] collection of if statement declarations def if_statements Rubyzen::Collections::DeclarationCollection.new(node.each_node(:if).map { |if_node| Rubyzen::Declarations::IfStatementDeclaration.new(if_node, self) }) end diff --git a/lib/rubyzen/providers/line_number_provider.rb b/lib/rubyzen/providers/line_number_provider.rb index 745f0a5..a3feee7 100644 --- a/lib/rubyzen/providers/line_number_provider.rb +++ b/lib/rubyzen/providers/line_number_provider.rb @@ -1,6 +1,8 @@ module Rubyzen module Providers + # Provides access to the starting line number of a declaration in its source file. module LineNumberProvider + # @return [Integer] the line number where this declaration begins def line node.loc.expression.line end diff --git a/lib/rubyzen/providers/lines_of_code_provider.rb b/lib/rubyzen/providers/lines_of_code_provider.rb index ff9e7b5..680438d 100644 --- a/lib/rubyzen/providers/lines_of_code_provider.rb +++ b/lib/rubyzen/providers/lines_of_code_provider.rb @@ -1,6 +1,8 @@ module Rubyzen module Providers + # Provides the number of lines of code a declaration spans. module LinesOfCodeProvider + # @return [Integer] the total number of source lines in this declaration def lines_of_code node.loc.expression.source.split("\n").size end diff --git a/lib/rubyzen/providers/macros_provider.rb b/lib/rubyzen/providers/macros_provider.rb index 46e8b1c..522f9aa 100644 --- a/lib/rubyzen/providers/macros_provider.rb +++ b/lib/rubyzen/providers/macros_provider.rb @@ -1,6 +1,8 @@ module Rubyzen module Providers + # Provides access to macro-style method calls (e.g., validates, has_many) within a declaration. module MacrosProvider + # @return [Rubyzen::Collections::MacrosCollection] collection of macro declarations def macros macros_nodes = node.each_descendant(:send).select(&:macro?) diff --git a/lib/rubyzen/providers/raises_provider.rb b/lib/rubyzen/providers/raises_provider.rb index 0dd82a1..55e163f 100644 --- a/lib/rubyzen/providers/raises_provider.rb +++ b/lib/rubyzen/providers/raises_provider.rb @@ -1,6 +1,8 @@ module Rubyzen module Providers + # Provides access to raise statements within a declaration. module RaisesProvider + # @return [Rubyzen::Collections::RaisesCollection] collection of raise declarations def raises raise_nodes = node.each_descendant(:send).select do |send_node| send_node.method_name == :raise diff --git a/lib/rubyzen/providers/requires_provider.rb b/lib/rubyzen/providers/requires_provider.rb index eacbc18..fe896e8 100644 --- a/lib/rubyzen/providers/requires_provider.rb +++ b/lib/rubyzen/providers/requires_provider.rb @@ -3,7 +3,9 @@ module Rubyzen module Providers + # Provides access to require, require_relative, and load statements within a declaration. module RequiresProvider + # @return [Rubyzen::Collections::RequiresCollection] collection of require declarations def requires require_nodes = node.each_descendant(:send).select do |send_node| %w[require require_relative load].include?(send_node.method_name.to_s) diff --git a/lib/rubyzen/providers/rescues_provider.rb b/lib/rubyzen/providers/rescues_provider.rb index 9d55c59..0fc193c 100644 --- a/lib/rubyzen/providers/rescues_provider.rb +++ b/lib/rubyzen/providers/rescues_provider.rb @@ -1,6 +1,8 @@ module Rubyzen module Providers + # Provides access to rescue clauses within a declaration. module RescuesProvider + # @return [Rubyzen::Collections::RescuesCollection] collection of rescue declarations def rescues rescue_nodes = node.each_descendant(:resbody) diff --git a/lib/rubyzen/providers/source_code_provider.rb b/lib/rubyzen/providers/source_code_provider.rb index 1fa4c73..ca89cb3 100644 --- a/lib/rubyzen/providers/source_code_provider.rb +++ b/lib/rubyzen/providers/source_code_provider.rb @@ -1,6 +1,8 @@ module Rubyzen module Providers + # Provides access to the raw source code text of a declaration. module SourceCodeProvider + # @return [String] the source code of this declaration def source_code node.loc.expression.source end diff --git a/lib/rubyzen/providers/visibility_provider.rb b/lib/rubyzen/providers/visibility_provider.rb index 77ed77a..4b0d363 100644 --- a/lib/rubyzen/providers/visibility_provider.rb +++ b/lib/rubyzen/providers/visibility_provider.rb @@ -1,18 +1,23 @@ module Rubyzen module Providers + # Provides method visibility detection (public, private, protected). module VisibilityProvider + # @return [Symbol] the visibility of this declaration (:public, :private, or :protected) def visibility determine_visibility end + # @return [Boolean] true if this declaration is private def private? visibility == :private end + # @return [Boolean] true if this declaration is protected def protected? visibility == :protected end + # @return [Boolean] true if this declaration is public def public? visibility == :public end diff --git a/lib/rubyzen/rspec/rspec_config.rb b/lib/rubyzen/rspec/rspec_config.rb index c53bea3..7db0ae7 100644 --- a/lib/rubyzen/rspec/rspec_config.rb +++ b/lib/rubyzen/rspec/rspec_config.rb @@ -1,14 +1,16 @@ - +# Overrides RSpec's +expect+ method to restrict subjects to Rubyzen collection types. +# This ensures that architectural lint rules only operate on valid Rubyzen collections, +# raising an ArgumentError if an unsupported subject type is passed. module RSpec module Matchers alias_method :__original_expect, :expect # override the public `expect` entrypoint def expect(actual = nil, &block) - unless valid_collection_subject?(actual) + if block.nil? && !valid_collection_subject?(actual) raise ArgumentError, "Invalid subject for `expect`: " \ - "only Rubyzen::Domain::Collection or Array of them allowed, " \ + "only Rubyzen::Collections types allowed, " \ "but got #{actual.inspect}" end diff --git a/sample_project/spec/matchers/be_empty_matcher_spec.rb b/sample_project/spec/matchers/be_empty_matcher_spec.rb deleted file mode 100644 index 0ca5730..0000000 --- a/sample_project/spec/matchers/be_empty_matcher_spec.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -require_relative '../spec_helper' - -RSpec.describe 'be_empty' do - let(:test_item_class) { Struct.new(:name, :class_name, :file_path, :line, keyword_init: true) } - - let(:root_path) { File.expand_path('../..', __dir__) } - - def build_item(name:, class_name:, file_path:, line: 1) - test_item_class.new(name: name, class_name: class_name, file_path: file_path, line: line) - end - - it 'groups baseline, allowlist, and live violations in the failure output' do - baseline_item = build_item( - name: 'index', - class_name: 'Legacy::Controller', - file_path: File.join(root_path, 'src/controllers/legacy_controller.rb'), - line: 12 - ) - allowlisted_item = build_item( - name: 'show', - class_name: 'Allowed::Controller', - file_path: File.join(root_path, 'src/controllers/allowed_controller.rb'), - line: 18 - ) - violating_item = build_item( - name: 'create', - class_name: 'New::Controller', - file_path: File.join(root_path, 'src/controllers/new_controller.rb'), - line: 24 - ) - - matcher = be_empty_with_exceptions( - allowlist: ['Allowed::Controller'], - baseline: ['Legacy::Controller'] - ) - - expect(matcher.matches?([baseline_item, allowlisted_item, violating_item])).to be(false) - expect(matcher.failure_message).to include('Expected to be empty, but found live violations.') - expect(matcher.failure_message).to include('Violations:') - expect(matcher.failure_message).to include('New::Controller') - expect(matcher.failure_message).not_to include('Stale baseline entries:') - expect(matcher.failure_message).not_to include('Stale allowlist entries:') - end - - it 'matches baseline entries against file path suffixes' do - item = build_item( - name: 'call', - class_name: 'Repo::UserRepo', - file_path: File.join(root_path, 'src/repos/user_repo.rb'), - line: 7 - ) - - expect([item]).to be_empty_with_exceptions(baseline: ['src/repos/user_repo.rb']) - end - - it 'fails when a baseline entry is no longer needed' do - matcher = be_empty_with_exceptions(baseline: ['Legacy::Controller']) - - expect(matcher.matches?([])).to be(false) - expect(matcher.failure_message).to include('Expected to be empty, but found stale baseline entries.') - expect(matcher.failure_message).to include('Stale baseline entries:') - expect(matcher.failure_message).to include('Legacy::Controller') - end - - it 'uses a combined failure message when both live violations and stale baseline entries exist' do - violating_item = build_item( - name: 'create', - class_name: 'New::Controller', - file_path: File.join(root_path, 'src/controllers/new_controller.rb'), - line: 24 - ) - - matcher = be_empty_with_exceptions(baseline: ['Legacy::Controller']) - - expect(matcher.matches?([violating_item])).to be(false) - expect(matcher.failure_message).to include('Expected to be empty, but found live violations and stale baseline entries.') - expect(matcher.failure_message).to include('Violations:') - expect(matcher.failure_message).to include('Stale baseline entries:') - end - - it 'fails when an allowlist entry is no longer needed' do - matcher = be_empty_with_exceptions(allowlist: ['Allowed::Controller']) - - expect(matcher.matches?([])).to be(false) - expect(matcher.failure_message).to include('Expected to be empty, but found stale allowlist entries.') - expect(matcher.failure_message).to include('Stale allowlist entries:') - expect(matcher.failure_message).to include('Allowed::Controller') - end - - it 'uses a combined failure message when live violations and stale allowlist entries exist' do - violating_item = build_item( - name: 'create', - class_name: 'New::Controller', - file_path: File.join(root_path, 'src/controllers/new_controller.rb'), - line: 24 - ) - - matcher = be_empty_with_exceptions(allowlist: ['Allowed::Controller']) - - expect(matcher.matches?([violating_item])).to be(false) - expect(matcher.failure_message).to include('Expected to be empty, but found live violations and stale allowlist entries.') - expect(matcher.failure_message).to include('Violations:') - expect(matcher.failure_message).to include('Stale allowlist entries:') - end -end \ No newline at end of file diff --git a/sample_project/spec/matchers/be_true_matcher_spec.rb b/sample_project/spec/matchers/be_true_matcher_spec.rb deleted file mode 100644 index aa10a4d..0000000 --- a/sample_project/spec/matchers/be_true_matcher_spec.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -require_relative '../spec_helper' - -RSpec.describe 'be_true' do - let(:test_item_class) { Struct.new(:name, :class_name, :file_path, :line, keyword_init: true) } - - let(:root_path) { File.expand_path('../..', __dir__) } - - def build_item(class_name: 'Questions::Albums::Type', file_path: 'src/questions/albums/type.rb') - test_item_class.new( - name: 'private_album?', - class_name: class_name, - file_path: File.join(root_path, file_path), - line: 10 - ) - end - - it 'fails when a baseline entry is stale' do - matcher = be_true(baseline: ['Questions::Albums::Type']) { |_item| true } - - expect(matcher.matches?([build_item])).to be(false) - expect(matcher.failure_message).to include('Expected to return true for all elements, but found stale baseline entries.') - expect(matcher.failure_message).to include('Stale baseline entries:') - expect(matcher.failure_message).to include('Questions::Albums::Type') - end - - it 'treats baseline entries as expected failures when they still fail' do - matcher = be_true(baseline: ['Questions::Albums::Type']) { |_item| false } - - expect(matcher.matches?([build_item])).to be(true) - end - - it 'fails with expect syntax when baseline entry refers to a passing type' do - items = [build_item] - baseline = ['Questions::Albums::Type'] - matcher = be_true(baseline: baseline) { |_item| true } - - expect(matcher.matches?(items)).to be(false) - expect(matcher.failure_message).to include('Stale baseline entries:') - expect(matcher.failure_message).to include('Questions::Albums::Type') - end - - it 'fails with expect syntax when baseline entry refers to a non-existent type' do - items = [build_item] - baseline = ['Questions::Albums::NonExistentTypeThatWasJustDeleted'] - matcher = be_true(baseline: baseline) { |_item| true } - - expect(matcher.matches?(items)).to be(false) - expect(matcher.failure_message).to include('Stale baseline entries:') - expect(matcher.failure_message).to include('Questions::Albums::NonExistentTypeThatWasJustDeleted') - end - - it 'treats allowlisted entries as expected failures when they still fail' do - matcher = be_true(allowlist: ['Questions::Albums::Type']) { |_item| false } - - expect(matcher.matches?([build_item])).to be(true) - end - - it 'fails when an allowlist entry is stale' do - matcher = be_true(allowlist: ['Questions::Albums::Type']) { |_item| true } - - expect(matcher.matches?([build_item])).to be(false) - expect(matcher.failure_message).to include('Expected to return true for all elements, but found stale allowlist entries.') - expect(matcher.failure_message).to include('Stale allowlist entries:') - expect(matcher.failure_message).to include('Questions::Albums::Type') - end - -end diff --git a/spec/cache/parse_cache_spec.rb b/spec/cache/parse_cache_spec.rb new file mode 100644 index 0000000..cee3078 --- /dev/null +++ b/spec/cache/parse_cache_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' +require 'tempfile' + +RSpec.describe Rubyzen::Cache::ParseCache do + let(:cache) { Rubyzen::Cache::ParseCache.new } + + it 'returns the parsed result for a file' do + Tempfile.create(['test', '.rb']) do |f| + f.write('class Foo; end') + f.flush + + result = cache.fetch_or_parse(f.path) do + Rubyzen::Parsers::ASTParser.instance.parse_file(f.path) + end + + expect(result).to be_a(Rubyzen::Declarations::FileDeclaration) + expect(result.classes.first.name).to eq('Foo') + end + end + + it 'returns cached result on second call with same content' do + Tempfile.create(['test', '.rb']) do |f| + f.write('class Foo; end') + f.flush + + parse_count = 0 + 2.times do + cache.fetch_or_parse(f.path) do + parse_count += 1 + Rubyzen::Parsers::ASTParser.instance.parse_file(f.path) + end + end + + expect(parse_count).to eq(1) + end + end + + it 're-parses when file content changes' do + Tempfile.create(['test', '.rb']) do |f| + f.write('class Foo; end') + f.flush + + result1 = cache.fetch_or_parse(f.path) do + Rubyzen::Parsers::ASTParser.instance.parse_file(f.path) + end + + f.reopen(f.path, 'w') + f.write('class Bar; end') + f.flush + + result2 = cache.fetch_or_parse(f.path) do + Rubyzen::Parsers::ASTParser.instance.parse_file(f.path) + end + + expect(result1.classes.first.name).to eq('Foo') + expect(result2.classes.first.name).to eq('Bar') + end + end +end diff --git a/spec/collections/attributes_collection_spec.rb b/spec/collections/attributes_collection_spec.rb new file mode 100644 index 0000000..cc75e04 --- /dev/null +++ b/spec/collections/attributes_collection_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::AttributesCollection do + let(:file) do + parse_ruby(<<~RUBY) + class Foo + attr_reader :name + attr_writer :email + attr_accessor :age + end + RUBY + end + + let(:attributes) { file.classes.first.attributes } + + describe '#readers' do + it 'filters to reader attributes' do + result = attributes.readers + expect(result.map(&:name)).to contain_exactly('attr_reader', 'attr_accessor') + end + end + + describe '#writers' do + it 'filters to writer attributes' do + result = attributes.writers + expect(result.map(&:name)).to contain_exactly('attr_writer', 'attr_accessor') + end + end + + describe '#accessors' do + it 'filters to accessor attributes only' do + result = attributes.accessors + expect(result.size).to eq(1) + expect(result.first.name).to eq('attr_accessor') + end + end + + describe 'CollectionFilterProvider' do + it 'supports with_name' do + result = attributes.with_name('attr_reader') + expect(result.size).to eq(1) + end + end +end diff --git a/spec/collections/base_collection_spec.rb b/spec/collections/base_collection_spec.rb new file mode 100644 index 0000000..71fd215 --- /dev/null +++ b/spec/collections/base_collection_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::BaseCollection do + describe '#filter' do + it 'returns the same collection type' do + collection = Rubyzen::Collections::ClassesCollection.new + result = collection.filter { true } + expect(result).to be_a(Rubyzen::Collections::ClassesCollection) + end + + it 'filters elements by the block condition' do + file = parse_ruby(<<~RUBY) + class Foo; end + class Bar; end + RUBY + + classes = Rubyzen::Collections::ClassesCollection.new(file.classes) + result = classes.filter { |c| c.name == 'Foo' } + expect(result.size).to eq(1) + expect(result.first.name).to eq('Foo') + end + + it 'returns an enumerator when no block is given' do + collection = Rubyzen::Collections::ClassesCollection.new + expect(collection.filter).to be_a(Enumerator) + end + end + + describe 'select and reject are undefined' do + it 'raises NoMethodError for select' do + collection = Rubyzen::Collections::BaseCollection.new + expect { collection.select }.to raise_error(NoMethodError) + end + + it 'raises NoMethodError for reject' do + collection = Rubyzen::Collections::BaseCollection.new + expect { collection.reject }.to raise_error(NoMethodError) + end + end +end diff --git a/spec/collections/blocks_collection_spec.rb b/spec/collections/blocks_collection_spec.rb new file mode 100644 index 0000000..62ee8dc --- /dev/null +++ b/spec/collections/blocks_collection_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::BlocksCollection do + let(:file) do + parse_ruby(<<~RUBY) + class Foo + def bar + items.each { |i| i.save } + users.map { |u| u.name } + end + end + RUBY + end + + let(:blocks) { file.classes.first.instance_methods.first.blocks } + + describe '#with_method_name' do + it 'filters blocks by the method they are passed to' do + result = blocks.with_method_name('each') + expect(result.size).to eq(1) + end + end + + describe '#call_sites' do + it 'returns all call sites across blocks' do + sites = blocks.call_sites + expect(sites).to be_a(Rubyzen::Collections::CallSiteCollection) + expect(sites.map(&:method_name)).to include('save', 'name') + end + end + + describe 'CollectionFilterProvider' do + it 'supports with_name' do + result = blocks.with_name('each') + expect(result.size).to eq(1) + end + end +end diff --git a/spec/collections/call_site_collection_spec.rb b/spec/collections/call_site_collection_spec.rb new file mode 100644 index 0000000..a39b186 --- /dev/null +++ b/spec/collections/call_site_collection_spec.rb @@ -0,0 +1,60 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::CallSiteCollection do + let(:file) do + parse_ruby(<<~RUBY) + class Foo + def bar + User.find(1) + logger.info(:debug, details: "msg") + save + end + end + RUBY + end + + let(:call_sites) { file.classes.first.instance_methods.first.call_sites } + + describe '#with_receiver' do + it 'filters by receiver constant' do + result = call_sites.with_receiver('User') + expect(result.size).to eq(1) + expect(result.first.method_name).to eq('find') + end + end + + describe '#with_name / #with_method_name' do + it 'filters by method name' do + result = call_sites.with_name('save') + expect(result.size).to eq(1) + end + + it 'with_method_name is an alias' do + result = call_sites.with_method_name('save') + expect(result.size).to eq(1) + end + end + + describe '#with_symbol' do + it 'filters by symbol argument' do + result = call_sites.with_symbol(:debug) + expect(result.size).to eq(1) + expect(result.first.method_name).to eq('info') + end + end + + describe '#with_keyword_arg' do + it 'filters by keyword argument key' do + result = call_sites.with_keyword_arg(:details) + expect(result.size).to eq(1) + expect(result.first.method_name).to eq('info') + end + end + + describe 'CollectionFilterProvider' do + it 'supports with_name' do + result = call_sites.with_name('find') + expect(result.size).to eq(1) + end + end +end diff --git a/spec/collections/classes_collection_spec.rb b/spec/collections/classes_collection_spec.rb new file mode 100644 index 0000000..22189dc --- /dev/null +++ b/spec/collections/classes_collection_spec.rb @@ -0,0 +1,158 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::ClassesCollection do + let(:file) do + parse_ruby(<<~RUBY) + class Foo < ApplicationController + attr_reader :name + validates_required :name + + def bar; end + def baz; end + + def self.build; end + end + + class Qux < BaseModel + def compute; end + end + RUBY + end + + let(:classes) { Rubyzen::Collections::ClassesCollection.new(file.classes) } + + describe '#all_methods' do + it 'returns all instance and class methods' do + methods = classes.all_methods + expect(methods).to be_a(Rubyzen::Collections::MethodsCollection) + expect(methods.map(&:name)).to include('bar', 'baz', 'build', 'compute') + end + end + + describe '#attributes' do + it 'returns all attributes across classes' do + attrs = classes.attributes + expect(attrs).to be_a(Rubyzen::Collections::AttributesCollection) + expect(attrs.first.symbols).to eq(['name']) + end + end + + describe '#macros' do + it 'returns all macros across classes' do + macros = classes.macros + expect(macros).to be_a(Rubyzen::Collections::MacrosCollection) + expect(macros.map(&:name)).to include('validates_required') + end + end + + describe '#with_parent_prefix' do + it 'filters by superclass prefix' do + result = classes.with_parent_prefix('Application') + expect(result.size).to eq(1) + expect(result.first.name).to eq('Foo') + end + end + + describe '#with_macro_name' do + it 'filters classes that have a specific macro' do + result = classes.with_macro_name('validates_required') + expect(result.size).to eq(1) + expect(result.first.name).to eq('Foo') + end + end + + describe '#+' do + it 'returns a ClassesCollection' do + set1 = classes.filter { |c| c.name == 'Foo' } + set2 = classes.filter { |c| c.name == 'Qux' } + result = set1 + set2 + expect(result).to be_a(Rubyzen::Collections::ClassesCollection) + expect(result.size).to eq(2) + end + end + + describe '#rescues' do + it 'returns rescues from all classes' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + x + rescue StandardError + y + end + end + RUBY + + classes = Rubyzen::Collections::ClassesCollection.new(file.classes) + expect(classes.rescues).to be_a(Rubyzen::Collections::RescuesCollection) + expect(classes.rescues.size).to eq(1) + end + end + + describe '#raises' do + it 'returns raises from all classes' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + raise "oops" + end + end + RUBY + + classes = Rubyzen::Collections::ClassesCollection.new(file.classes) + expect(classes.raises.size).to eq(1) + end + end + + describe 'CollectionFilterProvider' do + it 'supports with_name' do + result = classes.with_name('Foo') + expect(result.size).to eq(1) + end + + it 'supports with_name_starting_with' do + result = classes.with_name_starting_with('F') + expect(result.size).to eq(1) + end + + it 'supports with_name_including' do + result = classes.with_name_including('oo') + expect(result.size).to eq(1) + end + + it 'supports case-insensitive with_name_including' do + result = classes.with_name_including('foo', case_sensitive: false) + expect(result.size).to eq(1) + end + + it 'supports without_name' do + result = classes.without_name('Foo') + expect(result.size).to eq(1) + expect(result.first.name).to eq('Qux') + end + + it 'supports without_name_ending_with' do + result = classes.without_name_ending_with('ux') + expect(result.size).to eq(1) + expect(result.first.name).to eq('Foo') + end + + it 'supports without_name_starting_with' do + result = classes.without_name_starting_with('Q') + expect(result.size).to eq(1) + expect(result.first.name).to eq('Foo') + end + + it 'supports without_name_including' do + result = classes.without_name_including('ux') + expect(result.size).to eq(1) + expect(result.first.name).to eq('Foo') + end + + it 'supports case-insensitive without_name_including' do + result = classes.without_name_including('FOO', case_sensitive: false) + expect(result.size).to eq(1) + expect(result.first.name).to eq('Qux') + end + end +end diff --git a/spec/collections/constants_collection_spec.rb b/spec/collections/constants_collection_spec.rb new file mode 100644 index 0000000..4de5de2 --- /dev/null +++ b/spec/collections/constants_collection_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::ConstantsCollection do + let(:file) do + parse_ruby(<<~RUBY) + MAX = 100 + MIN = 0 + class Foo; end + RUBY + end + + let(:constants) { file.constants } + + describe 'CollectionFilterProvider' do + it 'supports with_name' do + assignments = constants.filter(&:assignment?) + result = assignments.with_name('MAX') + expect(result.size).to eq(1) + end + end +end diff --git a/spec/collections/file_collection_spec.rb b/spec/collections/file_collection_spec.rb new file mode 100644 index 0000000..01eaea1 --- /dev/null +++ b/spec/collections/file_collection_spec.rb @@ -0,0 +1,100 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::FileCollection do + let(:file1) { parse_ruby('class Foo; end', file_path: '/app/controllers/foo.rb') } + let(:file2) { parse_ruby('class Bar; end', file_path: '/app/models/bar.rb') } + let(:collection) { Rubyzen::Collections::FileCollection.new([file1, file2]) } + + describe '#with_paths' do + it 'filters files by path substring' do + result = collection.with_paths('controllers/') + expect(result.size).to eq(1) + expect(result.first.name).to eq('foo.rb') + end + + it 'supports multiple path patterns' do + result = collection.with_paths('controllers/', 'models/') + expect(result.size).to eq(2) + end + end + + describe '#without_paths' do + it 'excludes files matching path' do + result = collection.without_paths('controllers/') + expect(result.size).to eq(1) + expect(result.first.name).to eq('bar.rb') + end + end + + describe '#classes' do + it 'returns a ClassesCollection from all files' do + result = collection.classes + expect(result).to be_a(Rubyzen::Collections::ClassesCollection) + expect(result.map(&:name)).to contain_exactly('Foo', 'Bar') + end + end + + describe '#modules' do + it 'returns a ModulesCollection' do + file = parse_ruby('module Admin; end', file_path: '/app/mod.rb') + coll = Rubyzen::Collections::FileCollection.new([file]) + expect(coll.modules).to be_a(Rubyzen::Collections::ModulesCollection) + expect(coll.modules.first.name).to eq('Admin') + end + end + + describe '#constants' do + it 'returns a ConstantsCollection' do + file = parse_ruby(<<~RUBY, file_path: '/app/config.rb') + MAX = 100 + x = 1 + RUBY + coll = Rubyzen::Collections::FileCollection.new([file]) + expect(coll.constants).to be_a(Rubyzen::Collections::ConstantsCollection) + end + end + + describe '#requires' do + it 'returns a RequiresCollection' do + file = parse_ruby(<<~RUBY, file_path: '/app/init.rb') + require "json" + x = 1 + RUBY + coll = Rubyzen::Collections::FileCollection.new([file]) + expect(coll.requires).to be_a(Rubyzen::Collections::RequiresCollection) + end + end + + describe '#call_sites' do + it 'returns a CallSiteCollection' do + file = parse_ruby('puts "hi"', file_path: '/app/run.rb') + coll = Rubyzen::Collections::FileCollection.new([file]) + expect(coll.call_sites).to be_a(Rubyzen::Collections::CallSiteCollection) + end + end + + describe '#blocks' do + it 'returns a BlocksCollection' do + file = parse_ruby('[1].each { |x| x }', file_path: '/app/run.rb') + coll = Rubyzen::Collections::FileCollection.new([file]) + expect(coll.blocks).to be_a(Rubyzen::Collections::BlocksCollection) + end + end + + describe 'CollectionFilterProvider' do + it 'supports with_name' do + result = collection.with_name('foo.rb') + expect(result.size).to eq(1) + end + + it 'supports with_name_ending_with' do + result = collection.with_name_ending_with('.rb') + expect(result.size).to eq(2) + end + + it 'supports without_name' do + result = collection.without_name('foo.rb') + expect(result.size).to eq(1) + end + end +end diff --git a/spec/collections/macros_collection_spec.rb b/spec/collections/macros_collection_spec.rb new file mode 100644 index 0000000..22045db --- /dev/null +++ b/spec/collections/macros_collection_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::MacrosCollection do + let(:file) do + parse_ruby(<<~RUBY) + class Foo + validates_required :name + belongs_to :user + end + RUBY + end + + let(:macros) { file.classes.first.macros } + + describe 'CollectionFilterProvider' do + it 'supports with_name' do + result = macros.with_name('validates_required') + expect(result.size).to eq(1) + end + + it 'supports without_name' do + result = macros.without_name('validates_required') + expect(result.first.name).to eq('belongs_to') + end + end +end diff --git a/spec/collections/methods_collection_spec.rb b/spec/collections/methods_collection_spec.rb new file mode 100644 index 0000000..ef79567 --- /dev/null +++ b/spec/collections/methods_collection_spec.rb @@ -0,0 +1,76 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::MethodsCollection do + let(:file) do + parse_ruby(<<~RUBY) + class Foo + def bar(x) + if active? + User.find(x) + end + end + + def baz + raise "oops" + rescue StandardError + handle + end + end + RUBY + end + + let(:methods) { file.classes.first.instance_methods } + + describe '#parameters' do + it 'returns all parameters across methods' do + params = methods.parameters + expect(params).to be_a(Rubyzen::Collections::ParametersCollection) + expect(params.map(&:name)).to include(:x) + end + end + + describe '#if_statements' do + it 'returns all if statements across methods' do + stmts = methods.if_statements + expect(stmts).to be_a(Rubyzen::Collections::DeclarationCollection) + expect(stmts.size).to eq(1) + end + end + + describe '#call_sites' do + it 'returns all call sites across methods' do + sites = methods.call_sites + expect(sites).to be_a(Rubyzen::Collections::CallSiteCollection) + expect(sites.map(&:method_name)).to include('find') + end + end + + describe '#rescues' do + it 'returns all rescues across methods' do + rescues = methods.rescues + expect(rescues).to be_a(Rubyzen::Collections::RescuesCollection) + expect(rescues.size).to eq(1) + end + end + + describe '#raises' do + it 'returns all raises across methods' do + raises = methods.raises + expect(raises).to be_a(Rubyzen::Collections::RaisesCollection) + expect(raises.size).to eq(1) + end + end + + describe 'CollectionFilterProvider' do + it 'supports with_name' do + result = methods.with_name('bar') + expect(result.size).to eq(1) + end + + it 'supports without_name' do + result = methods.without_name('bar') + expect(result.size).to eq(1) + expect(result.first.name).to eq('baz') + end + end +end diff --git a/spec/collections/modules_collection_spec.rb b/spec/collections/modules_collection_spec.rb new file mode 100644 index 0000000..b6986f6 --- /dev/null +++ b/spec/collections/modules_collection_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::ModulesCollection do + let(:file) do + parse_ruby(<<~RUBY) + module Admin + class UsersController; end + + def helper_method; end + + MAX = 10 + end + + module Api; end + RUBY + end + + let(:modules) { Rubyzen::Collections::ModulesCollection.new(file.modules) } + + describe '#all_methods' do + it 'returns methods from all modules' do + methods = modules.all_methods + expect(methods).to be_a(Rubyzen::Collections::MethodsCollection) + expect(methods.map(&:name)).to include('helper_method') + end + end + + describe '#classes' do + it 'returns classes from all modules' do + classes = modules.classes + expect(classes).to be_a(Rubyzen::Collections::ClassesCollection) + expect(classes.map(&:name_without_modules)).to include('UsersController') + end + end + + describe '#constants' do + it 'returns constants from all modules' do + constants = modules.constants + expect(constants).to be_a(Rubyzen::Collections::ConstantsCollection) + end + end + + describe 'CollectionFilterProvider' do + it 'supports with_name' do + result = modules.with_name('Admin') + expect(result.size).to eq(1) + end + end +end diff --git a/spec/collections/parameters_collection_spec.rb b/spec/collections/parameters_collection_spec.rb new file mode 100644 index 0000000..d6b8b8d --- /dev/null +++ b/spec/collections/parameters_collection_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::ParametersCollection do + let(:file) do + parse_ruby(<<~RUBY) + class Foo + def bar(name, age); end + end + RUBY + end + + let(:parameters) { file.classes.first.instance_methods.first.parameters } + + describe 'CollectionFilterProvider' do + it 'supports with_name' do + result = parameters.with_name(:name) + expect(result.size).to eq(1) + end + + it 'supports without_name' do + result = parameters.without_name(:name) + expect(result.size).to eq(1) + expect(result.first.name).to eq(:age) + end + end +end diff --git a/spec/collections/raises_collection_spec.rb b/spec/collections/raises_collection_spec.rb new file mode 100644 index 0000000..3aca27b --- /dev/null +++ b/spec/collections/raises_collection_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::RaisesCollection do + let(:file) do + parse_ruby(<<~RUBY) + class Foo + def bar + raise "string error" + raise ArgumentError, "bad" + end + end + RUBY + end + + let(:raises) { file.classes.first.instance_methods.first.raises } + + describe '#with_string' do + it 'filters to raises with string messages' do + result = raises.with_string + expect(result.size).to eq(1) + expect(result.first.message).to eq('string error') + end + end + + describe '#with_exception_type' do + it 'filters by exception class' do + result = raises.with_exception_type('ArgumentError') + expect(result.size).to eq(1) + end + + it 'returns empty for non-matching type' do + result = raises.with_exception_type('TypeError') + expect(result).to be_empty + end + end +end diff --git a/spec/collections/requires_collection_spec.rb b/spec/collections/requires_collection_spec.rb new file mode 100644 index 0000000..4d5ab39 --- /dev/null +++ b/spec/collections/requires_collection_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::RequiresCollection do + let(:file) do + parse_ruby(<<~RUBY) + require 'json' + require_relative 'helper' + load 'config.rb' + RUBY + end + + let(:requires) { file.requires } + + describe '#require_calls' do + it 'filters to require statements' do + result = requires.require_calls + expect(result.size).to eq(1) + expect(result.first.required_path).to eq('json') + end + end + + describe '#require_relative_calls' do + it 'filters to require_relative statements' do + result = requires.require_relative_calls + expect(result.size).to eq(1) + expect(result.first.required_path).to eq('helper') + end + end + + describe '#load_calls' do + it 'filters to load statements' do + result = requires.load_calls + expect(result.size).to eq(1) + expect(result.first.required_path).to eq('config.rb') + end + end + + describe 'CollectionFilterProvider' do + it 'supports with_name' do + result = requires.with_name('require') + expect(result.size).to eq(1) + end + end +end diff --git a/spec/collections/rescues_collection_spec.rb b/spec/collections/rescues_collection_spec.rb new file mode 100644 index 0000000..51ded5d --- /dev/null +++ b/spec/collections/rescues_collection_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::RescuesCollection do + let(:file) do + parse_ruby(<<~RUBY) + class Foo + def bar + begin + x + rescue ArgumentError + handle_arg + rescue TypeError + handle_type + end + end + end + RUBY + end + + let(:rescues) { file.classes.first.instance_methods.first.rescues } + + describe '#with_exception_type' do + it 'filters by exception class' do + result = rescues.with_exception_type('ArgumentError') + expect(result.size).to eq(1) + end + + it 'returns empty for non-matching type' do + result = rescues.with_exception_type('RuntimeError') + expect(result).to be_empty + end + end +end diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb new file mode 100644 index 0000000..99448c3 --- /dev/null +++ b/spec/configuration_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Configuration do + around do |example| + original = ENV['RUBYZEN_PROJECT_PATHS'] + example.run + ensure + if original + ENV['RUBYZEN_PROJECT_PATHS'] = original + else + ENV.delete('RUBYZEN_PROJECT_PATHS') + end + Rubyzen.instance_variable_set(:@configuration, nil) + end + + describe '#project_paths' do + it 'returns parsed paths from RUBYZEN_PROJECT_PATHS' do + ENV['RUBYZEN_PROJECT_PATHS'] = '/tmp,/usr' + config = Rubyzen::Configuration.new + expect(config.project_paths).to eq(['/tmp', '/usr']) + end + + it 'strips whitespace from paths' do + ENV['RUBYZEN_PROJECT_PATHS'] = ' /tmp , /usr ' + config = Rubyzen::Configuration.new + expect(config.project_paths).to eq(['/tmp', '/usr']) + end + + it 'rejects empty segments' do + ENV['RUBYZEN_PROJECT_PATHS'] = '/tmp,,/usr' + config = Rubyzen::Configuration.new + expect(config.project_paths).to eq(['/tmp', '/usr']) + end + end + + describe 'error handling' do + it 'raises when RUBYZEN_PROJECT_PATHS is not set' do + ENV.delete('RUBYZEN_PROJECT_PATHS') + expect { Rubyzen::Configuration.new }.to raise_error(RuntimeError, /RUBYZEN_PROJECT_PATHS/) + end + + it 'raises when a path does not exist' do + ENV['RUBYZEN_PROJECT_PATHS'] = '/nonexistent/path/abc123' + expect { Rubyzen::Configuration.new }.to raise_error(RuntimeError, /Directory not found/) + end + end + + describe 'Rubyzen.configuration' do + it 'returns a memoized Configuration instance' do + ENV['RUBYZEN_PROJECT_PATHS'] = '/tmp' + Rubyzen.instance_variable_set(:@configuration, nil) + config1 = Rubyzen.configuration + config2 = Rubyzen.configuration + expect(config1).to be(config2) + end + end +end diff --git a/spec/declarations/attribute_declaration_spec.rb b/spec/declarations/attribute_declaration_spec.rb new file mode 100644 index 0000000..5d8e76b --- /dev/null +++ b/spec/declarations/attribute_declaration_spec.rb @@ -0,0 +1,122 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::AttributeDeclaration do + def attributes_from(source) + file = parse_ruby(<<~RUBY) + class Foo + #{source} + end + RUBY + file.classes.first.attributes + end + + describe '#name' do + it 'returns attr_reader for readers' do + attrs = attributes_from('attr_reader :name') + expect(attrs.first.name).to eq('attr_reader') + end + + it 'returns attr_writer for writers' do + attrs = attributes_from('attr_writer :name') + expect(attrs.first.name).to eq('attr_writer') + end + + it 'returns attr_accessor for accessors' do + attrs = attributes_from('attr_accessor :name') + expect(attrs.first.name).to eq('attr_accessor') + end + end + + describe '#symbols' do + it 'returns the declared symbol names' do + attrs = attributes_from('attr_reader :name, :email') + expect(attrs.first.symbols).to eq(%w[name email]) + end + end + + describe '#reader?' do + it 'returns true for attr_reader' do + attrs = attributes_from('attr_reader :name') + expect(attrs.first.reader?).to be true + end + + it 'returns true for attr_accessor' do + attrs = attributes_from('attr_accessor :name') + expect(attrs.first.reader?).to be true + end + + it 'returns false for attr_writer' do + attrs = attributes_from('attr_writer :name') + expect(attrs.first.reader?).to be false + end + end + + describe '#writer?' do + it 'returns true for attr_writer' do + attrs = attributes_from('attr_writer :name') + expect(attrs.first.writer?).to be true + end + + it 'returns true for attr_accessor' do + attrs = attributes_from('attr_accessor :name') + expect(attrs.first.writer?).to be true + end + + it 'returns false for attr_reader' do + attrs = attributes_from('attr_reader :name') + expect(attrs.first.writer?).to be false + end + end + + describe '#accessor?' do + it 'returns true only for attr_accessor' do + attrs = attributes_from('attr_accessor :name') + expect(attrs.first.accessor?).to be true + end + + it 'returns false for attr_reader' do + attrs = attributes_from('attr_reader :name') + expect(attrs.first.accessor?).to be false + end + end + + describe '#visibility' do + it 'returns :public by default' do + attrs = attributes_from('attr_reader :name') + expect(attrs.first.visibility).to eq(:public) + end + + it 'returns :private when after private keyword' do + attrs = attributes_from("private\n attr_reader :secret") + expect(attrs.first.visibility).to eq(:private) + end + end + + describe '#class_name' do + it 'returns the enclosing class name' do + attrs = attributes_from('attr_reader :name') + expect(attrs.first.class_name).to eq('Foo') + end + end + + describe '#private? / #protected? / #public?' do + it 'returns true for public? by default' do + attrs = attributes_from('attr_reader :name') + expect(attrs.first.public?).to be true + expect(attrs.first.private?).to be false + expect(attrs.first.protected?).to be false + end + + it 'returns true for private? after private keyword' do + attrs = attributes_from("private\n attr_reader :secret") + expect(attrs.first.private?).to be true + expect(attrs.first.public?).to be false + end + + it 'returns true for protected? after protected keyword' do + attrs = attributes_from("protected\n attr_reader :internal") + expect(attrs.first.protected?).to be true + expect(attrs.first.public?).to be false + end + end +end diff --git a/spec/declarations/block_declaration_spec.rb b/spec/declarations/block_declaration_spec.rb new file mode 100644 index 0000000..9143007 --- /dev/null +++ b/spec/declarations/block_declaration_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::BlockDeclaration do + def blocks_from(source) + file = parse_ruby(<<~RUBY) + class Foo + def bar + #{source} + end + end + RUBY + file.classes.first.instance_methods.first.blocks + end + + describe '#name and #method_name' do + it 'returns the method the block is passed to' do + blocks = blocks_from('items.each { |i| puts i }') + expect(blocks.first.name).to eq('each') + expect(blocks.first.method_name).to eq('each') + end + end + + describe '#call_sites' do + it 'returns call sites within the block' do + blocks = blocks_from('[1].map { |x| x.to_s }') + sites = blocks.first.call_sites + expect(sites.map(&:method_name)).to include('to_s') + end + end + + describe '#lines_of_code' do + it 'returns the line count of the block' do + blocks = blocks_from("items.each do |i|\n puts i\nend") + expect(blocks.first.lines_of_code).to be >= 1 + end + end + + describe '#source_code' do + it 'returns the source code of the block' do + blocks = blocks_from('items.each { |i| puts i }') + expect(blocks.first.source_code).to include('each') + end + end + + describe '#rescues' do + it 'returns rescue declarations within the block' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + items.each do |i| + begin + i.save! + rescue ActiveRecord::RecordInvalid + handle + end + end + end + end + RUBY + + blocks = file.classes.first.instance_methods.first.blocks + expect(blocks.first.rescues.size).to eq(1) + end + end + + describe '#raises' do + it 'returns raise declarations within the block' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + items.each do |i| + raise ArgumentError, "bad" + end + end + end + RUBY + + blocks = file.classes.first.instance_methods.first.blocks + expect(blocks.first.raises.size).to eq(1) + end + end + + describe '#class_name' do + it 'returns the enclosing class name' do + blocks = blocks_from('items.each { |i| i }') + expect(blocks.first.class_name).to eq('Foo') + end + end + + describe '#file_path' do + it 'returns the file path' do + file = parse_ruby(<<~RUBY, file_path: '/app/foo.rb') + class Foo + def bar + items.each { |i| i } + end + end + RUBY + + block = file.classes.first.instance_methods.first.blocks.first + expect(block.file_path).to eq('/app/foo.rb') + end + end +end diff --git a/spec/declarations/call_site_declaration_spec.rb b/spec/declarations/call_site_declaration_spec.rb new file mode 100644 index 0000000..70d5326 --- /dev/null +++ b/spec/declarations/call_site_declaration_spec.rb @@ -0,0 +1,107 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::CallSiteDeclaration do + def call_sites_from(source) + file = parse_ruby(<<~RUBY) + class Foo + def bar + #{source} + end + end + RUBY + file.classes.first.instance_methods.first.call_sites + end + + describe '#name and #method_name' do + it 'returns the called method name' do + sites = call_sites_from('User.find(1)') + site = sites.with_name('find').first + expect(site.name).to eq('find') + expect(site.method_name).to eq('find') + end + end + + describe '#receiver' do + it 'returns the constant receiver name' do + sites = call_sites_from('User.find(1)') + site = sites.with_name('find').first + expect(site.receiver).to eq('User') + end + + it 'returns nil when receiver is not a constant' do + sites = call_sites_from('foo.bar') + site = sites.with_name('bar').first + expect(site.receiver).to be_nil + end + + it 'returns nil when there is no receiver' do + sites = call_sites_from('puts "hello"') + site = sites.with_name('puts').first + expect(site.receiver).to be_nil + end + end + + describe '#keyword_args' do + it 'returns keyword argument keys' do + sites = call_sites_from('log(level: :info, details: "msg")') + site = sites.with_name('log').first + expect(site.keyword_args).to contain_exactly(:level, :details) + end + + it 'returns empty array when no keyword args' do + sites = call_sites_from('puts "hello"') + site = sites.with_name('puts').first + expect(site.keyword_args).to be_empty + end + end + + describe '#keyword_arg_value_pairs' do + it 'returns keyword arg to value mapping' do + sites = call_sites_from('log(level: :info, count: 5)') + site = sites.with_name('log').first + pairs = site.keyword_arg_value_pairs + expect(pairs[:level]).to eq(:info) + expect(pairs[:count]).to eq(5) + end + end + + describe '#symbols' do + it 'returns positional symbol arguments' do + sites = call_sites_from('validates :name, :email') + site = sites.with_name('validates').first + expect(site.symbols).to eq([:name, :email]) + end + end + + describe '#strings' do + it 'returns positional string arguments' do + sites = call_sites_from('require "json"') + site = sites.with_name('require').first + expect(site.strings).to eq(['json']) + end + end + + describe '#source_code' do + it 'returns the source code of the call' do + sites = call_sites_from('puts "hello"') + site = sites.with_name('puts').first + expect(site.source_code).to eq('puts "hello"') + end + end + + describe '#line' do + it 'returns the line number' do + sites = call_sites_from('puts "hello"') + site = sites.with_name('puts').first + expect(site.line).to be_a(Integer) + end + end + + describe '#class_name' do + it 'returns the enclosing class name' do + sites = call_sites_from('puts "hello"') + site = sites.with_name('puts').first + expect(site.class_name).to eq('Foo') + end + end +end diff --git a/spec/declarations/class_declaration_spec.rb b/spec/declarations/class_declaration_spec.rb new file mode 100644 index 0000000..88fb630 --- /dev/null +++ b/spec/declarations/class_declaration_spec.rb @@ -0,0 +1,275 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::ClassDeclaration do + describe '#name' do + it 'returns the class name' do + file = parse_ruby('class UserController; end') + expect(file.classes.first.name).to eq('UserController') + end + + it 'includes parent module names' do + file = parse_ruby(<<~RUBY) + module Admin + class UserController; end + end + RUBY + + expect(file.classes.first.name).to eq('Admin::UserController') + end + + it 'includes nested module names' do + file = parse_ruby(<<~RUBY) + module Admin + module V2 + class UserController; end + end + end + RUBY + + expect(file.classes.first.name).to eq('Admin::V2::UserController') + end + end + + describe '#name_without_modules' do + it 'returns just the class name' do + file = parse_ruby(<<~RUBY) + module Admin + class UserController; end + end + RUBY + + expect(file.classes.first.name_without_modules).to eq('UserController') + end + end + + describe '#superclass_name' do + it 'returns the superclass name' do + file = parse_ruby('class Foo < Bar; end') + expect(file.classes.first.superclass_name).to eq('Bar') + end + + it 'returns nil when no superclass' do + file = parse_ruby('class Foo; end') + expect(file.classes.first.superclass_name).to be_nil + end + end + + describe '#superclass_prefix?' do + it 'returns true when superclass starts with prefix' do + file = parse_ruby('class Foo < ApplicationController; end') + expect(file.classes.first.superclass_prefix?('Application')).to be true + end + + it 'returns false when superclass does not match' do + file = parse_ruby('class Foo < Bar; end') + expect(file.classes.first.superclass_prefix?('Application')).to be false + end + end + + describe '#instance_methods' do + it 'returns instance methods' do + file = parse_ruby(<<~RUBY) + class Foo + def bar; end + def baz; end + end + RUBY + + methods = file.classes.first.instance_methods + expect(methods.map(&:name)).to eq(%w[bar baz]) + end + + it 'excludes class methods' do + file = parse_ruby(<<~RUBY) + class Foo + def self.class_method; end + def instance_method; end + end + RUBY + + methods = file.classes.first.instance_methods + expect(methods.map(&:name)).to eq(%w[instance_method]) + end + end + + describe '#class_methods' do + it 'returns self. methods' do + file = parse_ruby(<<~RUBY) + class Foo + def self.build; end + end + RUBY + + expect(file.classes.first.class_methods.map(&:name)).to eq(%w[build]) + end + + it 'returns methods from class << self blocks' do + file = parse_ruby(<<~RUBY) + class Foo + class << self + def build; end + end + end + RUBY + + expect(file.classes.first.class_methods.map(&:name)).to eq(%w[build]) + end + end + + describe '#called_method_names' do + it 'returns unique method names called in the class' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + puts "hello" + save + puts "world" + end + end + RUBY + + names = file.classes.first.called_method_names + expect(names).to include('puts', 'save') + end + end + + describe '#top_level_module' do + it 'returns the top level module name from the file' do + file = parse_ruby(<<~RUBY) + module Controllers + class Index; end + end + RUBY + + expect(file.classes.first.top_level_module).to eq('Controllers') + end + end + + describe '#attributes' do + it 'returns attribute declarations' do + file = parse_ruby(<<~RUBY) + class Foo + attr_reader :name + attr_accessor :email + end + RUBY + + attrs = file.classes.first.attributes + expect(attrs.map(&:name)).to eq(%w[attr_reader attr_accessor]) + end + end + + describe '#macros' do + it 'returns macro declarations' do + file = parse_ruby(<<~RUBY) + class Foo + validates_required :name + belongs_to :user + end + RUBY + + macros = file.classes.first.macros + expect(macros.map(&:name)).to include('validates_required', 'belongs_to') + end + end + + describe '#lines_of_code' do + it 'returns the number of lines' do + file = parse_ruby(<<~RUBY) + class Foo + def bar; end + end + RUBY + expect(file.classes.first.lines_of_code).to eq(3) + end + end + + describe '#file_path' do + it 'returns the file path from the file declaration' do + file = parse_ruby('class Foo; end', file_path: '/app/foo.rb') + expect(file.classes.first.file_path).to eq('/app/foo.rb') + end + end + + describe '#if_statements' do + it 'returns if statements within the class' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + if condition + something + end + end + end + RUBY + + expect(file.classes.first.if_statements.size).to eq(1) + end + end + + describe '#rescues' do + it 'returns rescue declarations' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + something + rescue StandardError + handle + end + end + RUBY + + expect(file.classes.first.rescues.size).to eq(1) + end + end + + describe '#raises' do + it 'returns raise declarations' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + raise ArgumentError, "bad" + end + end + RUBY + + expect(file.classes.first.raises.size).to eq(1) + end + end + + describe '#blocks' do + it 'returns blocks within the class' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + items.each { |i| i } + end + end + RUBY + + expect(file.classes.first.blocks.size).to eq(1) + expect(file.classes.first.blocks.first.method_name).to eq('each') + end + end + + describe '#constants' do + it 'returns constants within the class' do + file = parse_ruby(<<~RUBY) + class Foo + MAX = 100 + MIN = 0 + end + RUBY + + assignments = file.classes.first.constants.filter(&:assignment?) + expect(assignments.map(&:name)).to include('MAX', 'MIN') + end + end + + describe '#class_name' do + it 'returns its own name' do + file = parse_ruby('class Calculator; end') + expect(file.classes.first.class_name).to eq('Calculator') + end + end +end diff --git a/spec/declarations/constant_declaration_spec.rb b/spec/declarations/constant_declaration_spec.rb new file mode 100644 index 0000000..3e53b95 --- /dev/null +++ b/spec/declarations/constant_declaration_spec.rb @@ -0,0 +1,251 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::ConstantDeclaration do + describe '#name' do + it 'returns the constant name for assignments' do + file = parse_ruby(<<~RUBY) + MAX = 100 + x = 1 + RUBY + const = file.constants.filter(&:assignment?).first + expect(const.name).to eq('MAX') + end + + it 'returns the constant name for references' do + file = parse_ruby(<<~RUBY) + class Foo + def bar + MAX + end + end + RUBY + + refs = file.constants.filter(&:reference?) + expect(refs.map(&:name)).to include('MAX') + end + end + + describe '#value' do + it 'returns string values' do + file = parse_ruby(<<~RUBY) + NAME = "hello" + x = 1 + RUBY + const = file.constants.filter(&:assignment?).first + expect(const.value).to eq('hello') + end + + it 'returns integer values' do + file = parse_ruby(<<~RUBY) + MAX = 100 + x = 1 + RUBY + const = file.constants.filter(&:assignment?).first + expect(const.value).to eq(100) + end + + it 'returns boolean values' do + file = parse_ruby(<<~RUBY) + ENABLED = true + x = 1 + RUBY + const = file.constants.filter(&:assignment?).first + expect(const.value).to eq(true) + end + + it 'returns nil for references' do + file = parse_ruby('class Foo < Bar; end') + ref = file.constants.filter(&:reference?).first + expect(ref.value).to be_nil + end + end + + describe '#assignment?' do + it 'returns true for constant assignments' do + file = parse_ruby(<<~RUBY) + MAX = 100 + x = 1 + RUBY + const = file.constants.filter(&:assignment?).first + expect(const.assignment?).to be true + end + end + + describe '#reference?' do + it 'returns true for constant references' do + file = parse_ruby('class Foo; end') + ref = file.constants.filter(&:reference?).first + expect(ref.reference?).to be true + end + end + + describe '#top_level?' do + it 'returns true for constants defined at file scope' do + file = parse_ruby(<<~RUBY) + MAX = 100 + x = 1 + RUBY + const = file.constants.filter(&:assignment?).first + expect(const.top_level?).to be true + end + + it 'returns false for constants inside a class' do + file = parse_ruby(<<~RUBY) + class Foo + MAX = 100 + end + RUBY + + const = file.constants.filter(&:assignment?).first + expect(const.top_level?).to be false + end + end + + describe '#scoped?' do + it 'returns the opposite of top_level?' do + file = parse_ruby(<<~RUBY) + class Foo + MAX = 100 + end + RUBY + + const = file.constants.filter(&:assignment?).first + expect(const.scoped?).to be true + end + end + + describe '#source_code' do + it 'returns the source' do + file = parse_ruby(<<~RUBY) + MAX = 100 + x = 1 + RUBY + const = file.constants.filter(&:assignment?).first + expect(const.source_code).to eq('MAX = 100') + end + end + + describe '#value with float' do + it 'returns float values' do + file = parse_ruby(<<~RUBY) + RATE = 3.14 + x = 1 + RUBY + const = file.constants.filter(&:assignment?).first + expect(const.value).to eq(3.14) + end + end + + describe '#in_class?' do + it 'returns true when inside a class' do + file = parse_ruby(<<~RUBY) + class Foo + MAX = 100 + MIN = 0 + end + RUBY + + const = file.classes.first.constants.filter(&:assignment?).first + expect(const.in_class?).to be true + end + + it 'returns true when obtained via file.constants but inside a class' do + file = parse_ruby(<<~RUBY) + class Foo + MAX = 100 + end + RUBY + + const = file.constants.filter(&:assignment?).first + expect(const.in_class?).to be true + end + + it 'returns true when obtained via method.constants inside a class' do + file = parse_ruby(<<~RUBY) + class Foo + MAX = 100 + def bar + x = MAX + end + end + RUBY + + const = file.classes.first.instance_methods.first.constants.filter(&:reference?).first + expect(const.in_class?).to be true + end + + it 'returns false when at file level' do + file = parse_ruby(<<~RUBY) + MAX = 100 + x = 1 + RUBY + const = file.constants.filter(&:assignment?).first + expect(const.in_class?).to be false + end + end + + describe '#enclosing_class' do + it 'returns the class declaration when inside a class' do + file = parse_ruby(<<~RUBY) + class Foo + MAX = 100 + MIN = 0 + end + RUBY + + const = file.classes.first.constants.filter(&:assignment?).first + expect(const.enclosing_class).to be_a(Rubyzen::Declarations::ClassDeclaration) + end + + it 'returns the class declaration when obtained via method.constants' do + file = parse_ruby(<<~RUBY) + class Foo + MAX = 100 + def bar + x = MAX + end + end + RUBY + + const = file.classes.first.instance_methods.first.constants.filter(&:reference?).first + expect(const.enclosing_class).to be_a(Rubyzen::Declarations::ClassDeclaration) + end + + it 'returns nil when at file level' do + file = parse_ruby(<<~RUBY) + MAX = 100 + x = 1 + RUBY + const = file.constants.filter(&:assignment?).first + expect(const.enclosing_class).to be_nil + end + end + + describe '#in_module?' do + it 'returns true when inside a module' do + file = parse_ruby(<<~RUBY) + module Config + TIMEOUT = 30 + RETRIES = 3 + end + RUBY + + const = file.modules.first.constants.filter(&:assignment?).first + expect(const.in_module?).to be true + end + end + + describe '#enclosing_module' do + it 'returns the module declaration when inside a module' do + file = parse_ruby(<<~RUBY) + module Config + TIMEOUT = 30 + RETRIES = 3 + end + RUBY + + const = file.modules.first.constants.filter(&:assignment?).first + expect(const.enclosing_module).to be_a(Rubyzen::Declarations::ModuleDeclaration) + end + end +end diff --git a/spec/declarations/file_declaration_spec.rb b/spec/declarations/file_declaration_spec.rb new file mode 100644 index 0000000..973b281 --- /dev/null +++ b/spec/declarations/file_declaration_spec.rb @@ -0,0 +1,128 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::FileDeclaration do + describe '#name' do + it 'returns the basename of the file path' do + file = parse_ruby('class Foo; end', file_path: '/app/models/user.rb') + expect(file.name).to eq('user.rb') + end + end + + describe '#path' do + it 'returns the full file path' do + file = parse_ruby('class Foo; end', file_path: '/app/models/user.rb') + expect(file.path).to eq('/app/models/user.rb') + end + end + + describe '#classes' do + it 'returns all classes in the file' do + file = parse_ruby(<<~RUBY) + class Foo; end + class Bar; end + RUBY + + expect(file.classes.map(&:name)).to eq(%w[Foo Bar]) + end + + it 'returns empty array when no classes exist' do + file = parse_ruby('x = 1') + expect(file.classes).to be_empty + end + end + + describe '#modules' do + it 'returns all modules in the file' do + file = parse_ruby(<<~RUBY) + module Foo + module Bar; end + end + RUBY + + expect(file.modules.map(&:name_without_modules)).to include('Foo', 'Bar') + end + end + + describe '#top_level_module_name' do + it 'returns the first module name' do + file = parse_ruby(<<~RUBY) + module MyApp + class Foo; end + end + RUBY + + expect(file.top_level_module_name).to eq('MyApp') + end + + it 'returns nil when no modules exist' do + file = parse_ruby('class Foo; end') + expect(file.top_level_module_name).to be_nil + end + end + + describe '#lines_of_code' do + it 'counts the number of lines' do + file = parse_ruby(<<~RUBY) + class Foo + def bar; end + end + RUBY + expect(file.lines_of_code).to eq(3) + end + end + + describe '#constants' do + it 'returns constants in the file' do + file = parse_ruby(<<~RUBY) + MAX = 100 + class Foo; end + RUBY + + assignments = file.constants.filter(&:assignment?) + expect(assignments.map(&:name)).to include('MAX') + end + end + + describe '#requires' do + it 'returns require statements' do + file = parse_ruby(<<~RUBY) + require 'json' + require_relative 'helper' + RUBY + + expect(file.requires.size).to eq(2) + expect(file.requires.map(&:required_path)).to eq(%w[json helper]) + end + end + + describe '#call_sites' do + it 'returns all call sites in the file' do + file = parse_ruby(<<~RUBY) + puts "hello" + foo.bar + RUBY + + expect(file.call_sites.map(&:method_name)).to include('puts', 'bar') + end + end + + describe '#blocks' do + it 'returns all blocks in the file' do + file = parse_ruby(<<~RUBY) + [1, 2].each do |x| + puts x + end + x = 1 + RUBY + + expect(file.blocks.map(&:method_name)).to eq(['each']) + end + end + + describe '#file_path' do + it 'returns the path via FilePathProvider' do + file = parse_ruby('x = 1', file_path: '/app/test.rb') + expect(file.file_path).to eq('/app/test.rb') + end + end +end diff --git a/spec/declarations/if_statement_declaration_spec.rb b/spec/declarations/if_statement_declaration_spec.rb new file mode 100644 index 0000000..42cc830 --- /dev/null +++ b/spec/declarations/if_statement_declaration_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::IfStatementDeclaration do + def if_statements_from(source) + file = parse_ruby(<<~RUBY) + class Foo + def bar + #{source} + end + end + RUBY + file.classes.first.instance_methods.first.if_statements + end + + describe '#condition_source' do + it 'returns the condition as source code' do + stmts = if_statements_from("if active?\n do_something\nend") + expect(stmts.first.condition_source).to eq('active?') + end + end + + describe '#name' do + it 'returns the parent method name' do + stmts = if_statements_from("if true\n 1\nend") + expect(stmts.first.name).to eq('bar') + end + end + + describe '#source_code' do + it 'returns the full if statement source' do + stmts = if_statements_from("if active?\n do_something\nend") + expect(stmts.first.source_code).to include('if active?') + end + end + + describe '#line' do + it 'returns the line number' do + stmts = if_statements_from("if true\n 1\nend") + expect(stmts.first.line).to be_a(Integer) + end + end + + describe '#class_name' do + it 'returns the enclosing class name' do + stmts = if_statements_from("if true\n 1\nend") + expect(stmts.first.class_name).to eq('Foo') + end + end +end diff --git a/spec/declarations/macro_declaration_spec.rb b/spec/declarations/macro_declaration_spec.rb new file mode 100644 index 0000000..6a38521 --- /dev/null +++ b/spec/declarations/macro_declaration_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::MacroDeclaration do + def macros_from(source) + file = parse_ruby(<<~RUBY) + class Foo + #{source} + end + RUBY + file.classes.first.macros + end + + describe '#name' do + it 'returns the macro name' do + macros = macros_from('validates_required :name') + expect(macros.first.name).to eq('validates_required') + end + end + + describe '#symbols' do + it 'returns symbol arguments' do + macros = macros_from('validates_required :name, :email') + expect(macros.first.symbols).to eq([:name, :email]) + end + end + + describe '#strings' do + it 'returns string arguments' do + macros = macros_from('some_macro "path/to/file"') + expect(macros.first.strings).to eq(['path/to/file']) + end + end + + describe '#keyword_args' do + it 'returns keyword argument keys' do + macros = macros_from('belongs_to :user, foreign_key: :user_id, optional: true') + expect(macros.first.keyword_args).to contain_exactly(:foreign_key, :optional) + end + end + + describe '#receiver' do + it 'returns the receiver constant name' do + macros = macros_from('Config.setting :timeout') + macro = macros.filter { |m| m.name == 'setting' }.first + expect(macro&.receiver).to eq('Config') if macro + end + + it 'returns nil when no receiver' do + macros = macros_from('validates_required :name') + expect(macros.first.receiver).to be_nil + end + end + + describe '#source_code' do + it 'returns the source' do + macros = macros_from('validates_required :name') + expect(macros.first.source_code).to include('validates_required') + end + end + + describe '#class_name' do + it 'returns the enclosing class name' do + macros = macros_from('validates_required :name') + expect(macros.first.class_name).to eq('Foo') + end + end +end diff --git a/spec/declarations/method_declaration_spec.rb b/spec/declarations/method_declaration_spec.rb new file mode 100644 index 0000000..e4819e6 --- /dev/null +++ b/spec/declarations/method_declaration_spec.rb @@ -0,0 +1,238 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::MethodDeclaration do + def first_method(source) + parse_ruby(source).classes.first.instance_methods.first + end + + describe '#name' do + it 'returns the method name' do + method = first_method(<<~RUBY) + class Foo + def calculate; end + end + RUBY + + expect(method.name).to eq('calculate') + end + end + + describe '#parameters' do + it 'returns parameter declarations' do + method = first_method(<<~RUBY) + class Foo + def calculate(x, y); end + end + RUBY + + expect(method.parameters.map(&:name)).to eq([:x, :y]) + end + + it 'returns empty collection when no parameters' do + method = first_method(<<~RUBY) + class Foo + def calculate; end + end + RUBY + + expect(method.parameters).to be_empty + end + end + + describe '#parameters?' do + it 'returns true when method has parameters' do + method = first_method(<<~RUBY) + class Foo + def calculate(x); end + end + RUBY + + expect(method.parameters?).to be true + end + + it 'returns false when method has no parameters' do + method = first_method(<<~RUBY) + class Foo + def calculate; end + end + RUBY + + expect(method.parameters?).to be false + end + end + + describe '#call_sites' do + it 'returns call sites within the method' do + method = first_method(<<~RUBY) + class Foo + def calculate + User.find(1) + save + end + end + RUBY + + expect(method.call_sites.map(&:method_name)).to include('find', 'save') + end + end + + describe '#blocks' do + it 'returns blocks within the method' do + method = first_method(<<~RUBY) + class Foo + def calculate + items.each { |i| puts i } + end + end + RUBY + + expect(method.blocks.map(&:method_name)).to eq(['each']) + end + end + + describe '#if_statements' do + it 'returns if statements within the method' do + method = first_method(<<~RUBY) + class Foo + def calculate + if active? + do_something + end + end + end + RUBY + + expect(method.if_statements.size).to eq(1) + end + end + + describe '#lines_of_code' do + it 'returns the number of lines in the method' do + method = first_method(<<~RUBY) + class Foo + def calculate + x = 1 + y = 2 + x + y + end + end + RUBY + + expect(method.lines_of_code).to eq(5) + end + end + + describe '#visibility' do + it 'returns :public for public methods' do + method = first_method(<<~RUBY) + class Foo + def calculate; end + end + RUBY + + expect(method.visibility).to eq(:public) + expect(method.public?).to be true + end + + it 'returns :private for private methods' do + file = parse_ruby(<<~RUBY) + class Foo + private + + def secret; end + end + RUBY + + method = file.classes.first.instance_methods.first + expect(method.visibility).to eq(:private) + expect(method.private?).to be true + end + + it 'returns :protected for protected methods' do + file = parse_ruby(<<~RUBY) + class Foo + protected + + def internal; end + end + RUBY + + method = file.classes.first.instance_methods.first + expect(method.visibility).to eq(:protected) + expect(method.protected?).to be true + end + end + + describe '#rescues' do + it 'returns rescue declarations' do + method = first_method(<<~RUBY) + class Foo + def calculate + something + rescue ArgumentError + handle + end + end + RUBY + + expect(method.rescues.size).to eq(1) + expect(method.rescues.first.exception_types).to eq(['ArgumentError']) + end + end + + describe '#raises' do + it 'returns raise declarations' do + method = first_method(<<~RUBY) + class Foo + def calculate + raise RuntimeError, "oops" + end + end + RUBY + + expect(method.raises.size).to eq(1) + end + end + + describe '#constants' do + it 'returns constants referenced in the method' do + file = parse_ruby(<<~RUBY) + class Foo + MAX = 100 + + def calculate + x = MAX + end + end + RUBY + + method = file.classes.first.instance_methods.first + refs = method.constants.filter(&:reference?) + expect(refs.map(&:name)).to include('MAX') + end + end + + describe '#class_name' do + it 'returns the parent class name' do + method = first_method(<<~RUBY) + class Calculator + def compute; end + end + RUBY + + expect(method.class_name).to eq('Calculator') + end + end + + describe '#file_path' do + it 'returns the file path' do + file = parse_ruby(<<~RUBY, file_path: '/app/calc.rb') + class Foo + def bar; end + end + RUBY + + expect(file.classes.first.instance_methods.first.file_path).to eq('/app/calc.rb') + end + end +end diff --git a/spec/declarations/module_declaration_spec.rb b/spec/declarations/module_declaration_spec.rb new file mode 100644 index 0000000..71df22e --- /dev/null +++ b/spec/declarations/module_declaration_spec.rb @@ -0,0 +1,119 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::ModuleDeclaration do + describe '#name' do + it 'returns the module name' do + file = parse_ruby('module Controllers; end') + expect(file.modules.first.name).to eq('Controllers') + end + + it 'includes parent module names' do + file = parse_ruby(<<~RUBY) + module Admin + module Api; end + end + RUBY + + nested = file.modules.find { |m| m.name_without_modules == 'Api' } + expect(nested.name).to eq('Admin::Api') + end + end + + describe '#name_without_modules' do + it 'returns just the module name' do + file = parse_ruby(<<~RUBY) + module Admin + module Api; end + end + RUBY + + nested = file.modules.find { |m| m.name_without_modules == 'Api' } + expect(nested.name_without_modules).to eq('Api') + end + end + + describe '#classes' do + it 'returns classes within the module' do + file = parse_ruby(<<~RUBY) + module Admin + class UsersController; end + end + RUBY + + mod = file.modules.first + expect(mod.classes.map(&:name_without_modules)).to eq(['UsersController']) + end + end + + describe '#modules' do + it 'returns nested modules' do + file = parse_ruby(<<~RUBY) + module Admin + module V2; end + end + RUBY + + mod = file.modules.first + expect(mod.modules.map(&:name_without_modules)).to include('V2') + end + end + + describe '#all_methods' do + it 'returns methods defined directly in the module' do + file = parse_ruby(<<~RUBY) + module Helpers + def format_date; end + def format_time; end + end + RUBY + + mod = file.modules.first + expect(mod.all_methods.map(&:name)).to eq(%w[format_date format_time]) + end + end + + describe '#constants' do + it 'returns constants in the module' do + file = parse_ruby(<<~RUBY) + module Config + TIMEOUT = 30 + end + RUBY + + mod = file.modules.first + assignments = mod.constants.filter(&:assignment?) + expect(assignments.map(&:name)).to include('TIMEOUT') + end + end + + describe '#attributes' do + it 'returns attributes in the module' do + file = parse_ruby(<<~RUBY) + module Helpers + attr_reader :logger + end + RUBY + + mod = file.modules.first + expect(mod.attributes.first.symbols).to eq(['logger']) + end + end + + describe '#lines_of_code' do + it 'returns the line count' do + file = parse_ruby(<<~RUBY) + module Foo + def bar; end + end + RUBY + expect(file.modules.first.lines_of_code).to eq(3) + end + end + + describe '#file_path' do + it 'returns the file path' do + file = parse_ruby('module Foo; end', file_path: '/app/foo.rb') + expect(file.modules.first.file_path).to eq('/app/foo.rb') + end + end +end diff --git a/spec/declarations/parameter_declaration_spec.rb b/spec/declarations/parameter_declaration_spec.rb new file mode 100644 index 0000000..b0bd132 --- /dev/null +++ b/spec/declarations/parameter_declaration_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::ParameterDeclaration do + describe '#name' do + it 'returns the parameter name' do + file = parse_ruby(<<~RUBY) + class Foo + def bar(name, age); end + end + RUBY + + params = file.classes.first.instance_methods.first.parameters + expect(params.map(&:name)).to eq([:name, :age]) + end + end + + describe '#default_value' do + it 'returns the default value when present' do + file = parse_ruby(<<~RUBY) + class Foo + def bar(x = 42); end + end + RUBY + + param = file.classes.first.instance_methods.first.parameters.first + expect(param.default_value).to eq(42) + end + + it 'returns nil when no default value' do + file = parse_ruby(<<~RUBY) + class Foo + def bar(x); end + end + RUBY + + param = file.classes.first.instance_methods.first.parameters.first + expect(param.default_value).to be_nil + end + end + + describe '#class_name' do + it 'returns the enclosing class name' do + file = parse_ruby(<<~RUBY) + class Calculator + def compute(x); end + end + RUBY + + param = file.classes.first.instance_methods.first.parameters.first + expect(param.class_name).to eq('Calculator') + end + end + + describe '#file_path' do + it 'returns the file path' do + file = parse_ruby(<<~RUBY, file_path: '/app/calc.rb') + class Foo + def bar(x); end + end + RUBY + + param = file.classes.first.instance_methods.first.parameters.first + expect(param.file_path).to eq('/app/calc.rb') + end + end + + describe '#line' do + it 'returns the line number' do + file = parse_ruby(<<~RUBY) + class Foo + def bar(x); end + end + RUBY + + param = file.classes.first.instance_methods.first.parameters.first + expect(param.line).to be_a(Integer) + end + end +end diff --git a/spec/declarations/raise_declaration_spec.rb b/spec/declarations/raise_declaration_spec.rb new file mode 100644 index 0000000..859bb85 --- /dev/null +++ b/spec/declarations/raise_declaration_spec.rb @@ -0,0 +1,84 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::RaiseDeclaration do + def raises_from(source) + file = parse_ruby(<<~RUBY) + class Foo + def bar + #{source} + end + end + RUBY + file.classes.first.instance_methods.first.raises + end + + describe '#exception_types' do + it 'returns the exception class for raise with constant' do + raises = raises_from('raise ArgumentError, "bad"') + expect(raises.first.exception_types).to eq(['ArgumentError']) + end + + it 'returns RuntimeError for bare raise with string' do + raises = raises_from('raise "something went wrong"') + expect(raises.first.exception_types).to eq(['RuntimeError']) + end + + it 'returns RuntimeError for bare raise' do + raises = raises_from('raise') + expect(raises.first.exception_types).to eq(['RuntimeError']) + end + + it 'handles raise with .new' do + raises = raises_from('raise ArgumentError.new("bad")') + expect(raises.first.exception_types).to eq(['ArgumentError']) + end + end + + describe '#with_string?' do + it 'returns true when raising with a string' do + raises = raises_from('raise "oops"') + expect(raises.first.with_string?).to be true + end + + it 'returns false when raising with an exception class' do + raises = raises_from('raise ArgumentError, "bad"') + expect(raises.first.with_string?).to be false + end + end + + describe '#message' do + it 'returns the string message for bare string raises' do + raises = raises_from('raise "something went wrong"') + expect(raises.first.message).to eq('something went wrong') + end + + it 'returns the message for exception class raises' do + raises = raises_from('raise ArgumentError, "bad input"') + expect(raises.first.message).to eq('bad input') + end + + it 'returns the message for .new raises' do + raises = raises_from('raise ArgumentError.new("bad input")') + expect(raises.first.message).to eq('bad input') + end + + it 'returns nil when no message' do + raises = raises_from('raise') + expect(raises.first.message).to be_nil + end + end + + describe '#source_code' do + it 'returns the raise source' do + raises = raises_from('raise ArgumentError, "bad"') + expect(raises.first.source_code).to include('raise') + end + end + + describe '#class_name' do + it 'returns the enclosing class name' do + raises = raises_from('raise "oops"') + expect(raises.first.class_name).to eq('Foo') + end + end +end diff --git a/spec/declarations/require_declaration_spec.rb b/spec/declarations/require_declaration_spec.rb new file mode 100644 index 0000000..3bd9bfd --- /dev/null +++ b/spec/declarations/require_declaration_spec.rb @@ -0,0 +1,69 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::RequireDeclaration do + def requires_from(source) + parse_ruby(<<~RUBY).requires + #{source} + x = 1 + RUBY + end + + describe '#name' do + it 'returns "require" for require calls' do + reqs = requires_from('require "json"') + expect(reqs.first.name).to eq('require') + end + + it 'returns "require_relative" for require_relative calls' do + reqs = requires_from('require_relative "helper"') + expect(reqs.first.name).to eq('require_relative') + end + end + + describe '#required_path' do + it 'returns the required path' do + reqs = requires_from('require "json"') + expect(reqs.first.required_path).to eq('json') + end + end + + describe '#require?' do + it 'returns true for require' do + reqs = requires_from('require "json"') + expect(reqs.first.require?).to be true + expect(reqs.first.require_relative?).to be false + end + end + + describe '#require_relative?' do + it 'returns true for require_relative' do + reqs = requires_from('require_relative "helper"') + expect(reqs.first.require_relative?).to be true + expect(reqs.first.require?).to be false + end + end + + describe '#load?' do + it 'returns true for load calls' do + reqs = requires_from('load "config.rb"') + expect(reqs.first.load?).to be true + end + end + + describe '#file_path' do + it 'returns the file path' do + file = parse_ruby(<<~RUBY, file_path: '/app/init.rb') + require "json" + x = 1 + RUBY + expect(file.requires.first.file_path).to eq('/app/init.rb') + end + end + + describe '#line' do + it 'returns the line number' do + reqs = requires_from('require "json"') + expect(reqs.first.line).to eq(1) + end + end +end diff --git a/spec/declarations/rescue_declaration_spec.rb b/spec/declarations/rescue_declaration_spec.rb new file mode 100644 index 0000000..563a905 --- /dev/null +++ b/spec/declarations/rescue_declaration_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::RescueDeclaration do + def rescues_from(source) + file = parse_ruby(<<~RUBY) + class Foo + def bar + #{source} + end + end + RUBY + file.classes.first.instance_methods.first.rescues + end + + describe '#exception_types' do + it 'returns the rescued exception class' do + rescues = rescues_from("begin\n something\nrescue ArgumentError\n handle\nend") + expect(rescues.first.exception_types).to eq(['ArgumentError']) + end + + it 'returns multiple exception types' do + rescues = rescues_from("begin\n something\nrescue ArgumentError, TypeError\n handle\nend") + expect(rescues.first.exception_types).to eq(%w[ArgumentError TypeError]) + end + + it 'returns StandardError for bare rescue' do + rescues = rescues_from("begin\n something\nrescue\n handle\nend") + expect(rescues.first.exception_types).to eq(['StandardError']) + end + end + + describe '#class_name' do + it 'returns the enclosing class name' do + rescues = rescues_from("begin\n x\nrescue\n y\nend") + expect(rescues.first.class_name).to eq('Foo') + end + end + + describe '#file_path' do + it 'returns the file path' do + file = parse_ruby(<<~RUBY, file_path: '/app/foo.rb') + class Foo + def bar + begin + x + rescue + y + end + end + end + RUBY + + rescue_decl = file.classes.first.instance_methods.first.rescues.first + expect(rescue_decl.file_path).to eq('/app/foo.rb') + end + end + + describe '#line' do + it 'returns the line number' do + rescues = rescues_from("begin\n x\nrescue\n y\nend") + expect(rescues.first.line).to be_a(Integer) + end + end +end diff --git a/spec/fixtures/controllers/sample_controller.rb b/spec/fixtures/controllers/sample_controller.rb new file mode 100644 index 0000000..4cce2b8 --- /dev/null +++ b/spec/fixtures/controllers/sample_controller.rb @@ -0,0 +1,7 @@ +module Controllers + class SampleController + def index + "hello" + end + end +end diff --git a/spec/fixtures/models/sample_model.rb b/spec/fixtures/models/sample_model.rb new file mode 100644 index 0000000..f343edd --- /dev/null +++ b/spec/fixtures/models/sample_model.rb @@ -0,0 +1,7 @@ +class SampleModel < ActiveRecord::Base + attr_reader :name + + def full_name + name + end +end diff --git a/spec/matchers/be_empty_matcher_spec.rb b/spec/matchers/be_empty_matcher_spec.rb new file mode 100644 index 0000000..1a91fc6 --- /dev/null +++ b/spec/matchers/be_empty_matcher_spec.rb @@ -0,0 +1,75 @@ +require 'spec_helper' + +RSpec.describe 'be_empty matcher' do + let(:empty_collection) { Rubyzen::Collections::ClassesCollection.new } + let(:non_empty_collection) do + file = parse_ruby('class Foo; end') + Rubyzen::Collections::ClassesCollection.new(file.classes) + end + + it 'passes when collection is empty' do + expect(empty_collection).to be_empty + end + + it 'fails when collection is not empty' do + expect { + expect(non_empty_collection).to be_empty + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /Expected to be empty/) + end + + it 'supports custom failure message' do + expect { + expect(non_empty_collection).to be_empty("Controllers should not have violations") + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /Controllers should not have violations/) + end + + it 'supports negation' do + expect(non_empty_collection).not_to be_empty + end + + describe 'with allowlist' do + let(:classes) do + file = parse_ruby(<<~RUBY, file_path: '/app/controllers/foo_controller.rb') + class FooController; end + class BarController; end + RUBY + Rubyzen::Collections::ClassesCollection.new(file.classes) + end + + it 'passes when all items are allowlisted' do + expect(classes).to be_empty(allowlist: ['FooController', 'BarController']) + end + + it 'fails when there are non-allowlisted items' do + expect { + expect(classes).to be_empty(allowlist: ['FooController']) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /violations/i) + end + + it 'fails on stale allowlist entries' do + expect { + expect(classes).to be_empty(allowlist: ['FooController', 'BarController', 'NonExistent']) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /stale/i) + end + end + + describe 'with baseline' do + let(:classes) do + file = parse_ruby(<<~RUBY, file_path: '/app/controllers/foo_controller.rb') + class FooController; end + class BarController; end + RUBY + Rubyzen::Collections::ClassesCollection.new(file.classes) + end + + it 'passes when all items are in baseline' do + expect(classes).to be_empty(baseline: ['FooController', 'BarController']) + end + + it 'fails on stale baseline entries' do + expect { + expect(classes).to be_empty(baseline: ['FooController', 'BarController', 'OldClass']) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /stale/i) + end + end +end diff --git a/spec/matchers/be_empty_with_exceptions_matcher_spec.rb b/spec/matchers/be_empty_with_exceptions_matcher_spec.rb new file mode 100644 index 0000000..64541d9 --- /dev/null +++ b/spec/matchers/be_empty_with_exceptions_matcher_spec.rb @@ -0,0 +1,76 @@ +require 'spec_helper' + +RSpec.describe 'be_empty_with_exceptions matcher' do + let(:file) do + parse_ruby(<<~RUBY, file_path: '/app/controllers/foo_controller.rb') + class FooController; end + class BarController; end + RUBY + end + + let(:classes) { Rubyzen::Collections::ClassesCollection.new(file.classes) } + + describe 'with allowlist' do + it 'passes when all items are allowlisted' do + expect(classes).to be_empty_with_exceptions( + allowlist: ['FooController', 'BarController'] + ) + end + + it 'fails when there are non-allowlisted items' do + expect { + expect(classes).to be_empty_with_exceptions( + allowlist: ['FooController'] + ) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /violations/i) + end + + it 'fails on stale allowlist entries' do + expect { + expect(classes).to be_empty_with_exceptions( + allowlist: ['FooController', 'BarController', 'NonExistent'] + ) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /stale/i) + end + end + + describe 'with baseline' do + it 'passes when all items are in baseline' do + expect(classes).to be_empty_with_exceptions( + baseline: ['FooController', 'BarController'] + ) + end + + it 'fails on stale baseline entries' do + expect { + expect(classes).to be_empty_with_exceptions( + baseline: ['FooController', 'BarController', 'OldClass'] + ) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /stale/i) + end + end + + describe 'deprecation' do + it 'emits a deprecation warning' do + expect { + expect(classes).to be_empty_with_exceptions( + allowlist: ['FooController', 'BarController'] + ) + }.to output(/DEPRECATION/).to_stderr + end + end + + describe 'with empty collection' do + let(:empty) { Rubyzen::Collections::ClassesCollection.new } + + it 'passes with no exceptions' do + expect(empty).to be_empty_with_exceptions + end + + it 'fails on stale baseline with empty collection' do + expect { + expect(empty).to be_empty_with_exceptions(baseline: ['Ghost']) + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /stale/i) + end + end +end diff --git a/spec/matchers/be_false_matcher_spec.rb b/spec/matchers/be_false_matcher_spec.rb new file mode 100644 index 0000000..32cf976 --- /dev/null +++ b/spec/matchers/be_false_matcher_spec.rb @@ -0,0 +1,107 @@ +require 'spec_helper' + +RSpec.describe 'be_false matcher' do + let(:file) do + parse_ruby(<<~RUBY) + class Foo + def bar; end + def baz; end + end + RUBY + end + + let(:methods) { file.classes.first.instance_methods } + + it 'passes when block returns false for all elements' do + expect(methods).to be_false { |m| m.parameters? } + end + + it 'fails when block returns true for any element' do + file = parse_ruby(<<~RUBY) + class Foo + def bar(x); end + def baz; end + end + RUBY + + methods = file.classes.first.instance_methods + expect { + expect(methods).to be_false { |m| m.parameters? } + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /Expected to return false for all elements/) + end + + it 'fails when no block is given' do + expect { + expect(methods).to be_false + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /Expected a block/) + end + + it 'supports custom failure message' do + file = parse_ruby(<<~RUBY) + class Foo + def bar(x); end + end + RUBY + + methods = file.classes.first.instance_methods + expect { + expect(methods).to be_false("No methods should have params") { |m| m.parameters? } + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /No methods should have params/) + end + + describe 'with allowlist' do + it 'passes when failing items are allowlisted' do + file = parse_ruby(<<~RUBY) + class Foo + def bar(x); end + def baz; end + end + RUBY + + methods = file.classes.first.instance_methods + expect(methods).to be_false(allowlist: ['bar']) { |m| m.parameters? } + end + + it 'fails on stale allowlist entries' do + file = parse_ruby(<<~RUBY) + class Foo + def bar; end + def baz; end + end + RUBY + + methods = file.classes.first.instance_methods + expect { + expect(methods).to be_false(allowlist: ['nonexistent']) { |m| m.parameters? } + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /stale/i) + end + end + + describe 'with baseline' do + it 'passes when failing items are in baseline' do + file = parse_ruby(<<~RUBY) + class Foo + def bar(x); end + def baz; end + end + RUBY + + methods = file.classes.first.instance_methods + expect(methods).to be_false(baseline: ['bar']) { |m| m.parameters? } + end + + it 'fails on stale baseline entries' do + file = parse_ruby(<<~RUBY) + class Foo + def bar; end + def baz; end + end + RUBY + + methods = file.classes.first.instance_methods + expect { + expect(methods).to be_false(baseline: ['ghost']) { |m| m.parameters? } + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /stale/i) + end + end +end diff --git a/spec/matchers/be_true_matcher_spec.rb b/spec/matchers/be_true_matcher_spec.rb new file mode 100644 index 0000000..ddd40c3 --- /dev/null +++ b/spec/matchers/be_true_matcher_spec.rb @@ -0,0 +1,107 @@ +require 'spec_helper' + +RSpec.describe 'be_true matcher' do + let(:file) do + parse_ruby(<<~RUBY) + class Foo + def bar(x); end + def baz(y); end + end + RUBY + end + + let(:methods) { file.classes.first.instance_methods } + + it 'passes when block returns true for all elements' do + expect(methods).to be_true { |m| m.parameters? } + end + + it 'fails when block returns false for any element' do + file = parse_ruby(<<~RUBY) + class Foo + def bar(x); end + def baz; end + end + RUBY + + methods = file.classes.first.instance_methods + expect { + expect(methods).to be_true { |m| m.parameters? } + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /Expected to return true for all elements/) + end + + it 'fails when no block is given' do + expect { + expect(methods).to be_true + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /Expected a block/) + end + + it 'supports custom failure message' do + file = parse_ruby(<<~RUBY) + class Foo + def bar; end + end + RUBY + + methods = file.classes.first.instance_methods + expect { + expect(methods).to be_true("All methods must have params") { |m| m.parameters? } + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /All methods must have params/) + end + + describe 'with allowlist' do + it 'passes when failing items are allowlisted' do + file = parse_ruby(<<~RUBY) + class Foo + def bar(x); end + def baz; end + end + RUBY + + methods = file.classes.first.instance_methods + expect(methods).to be_true(allowlist: ['baz']) { |m| m.parameters? } + end + + it 'fails on stale allowlist entries' do + file = parse_ruby(<<~RUBY) + class Foo + def bar(x); end + def baz(y); end + end + RUBY + + methods = file.classes.first.instance_methods + expect { + expect(methods).to be_true(allowlist: ['nonexistent']) { |m| m.parameters? } + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /stale/i) + end + end + + describe 'with baseline' do + it 'passes when failing items are in baseline' do + file = parse_ruby(<<~RUBY) + class Foo + def bar(x); end + def baz; end + end + RUBY + + methods = file.classes.first.instance_methods + expect(methods).to be_true(baseline: ['baz']) { |m| m.parameters? } + end + + it 'fails on stale baseline entries' do + file = parse_ruby(<<~RUBY) + class Foo + def bar(x); end + def baz(y); end + end + RUBY + + methods = file.classes.first.instance_methods + expect { + expect(methods).to be_true(baseline: ['ghost']) { |m| m.parameters? } + }.to raise_error(RSpec::Expectations::ExpectationNotMetError, /stale/i) + end + end +end diff --git a/spec/parsers/ast_parser_spec.rb b/spec/parsers/ast_parser_spec.rb new file mode 100644 index 0000000..df3e4b0 --- /dev/null +++ b/spec/parsers/ast_parser_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' +require 'tempfile' + +RSpec.describe Rubyzen::Parsers::ASTParser do + describe '.instance' do + it 'returns the same instance' do + expect(Rubyzen::Parsers::ASTParser.instance).to be(Rubyzen::Parsers::ASTParser.instance) + end + end + + describe '#parse_file' do + it 'returns a FileDeclaration for valid Ruby' do + Tempfile.create(['valid', '.rb']) do |f| + f.write("class Foo; end\nx = 1") + f.flush + + result = Rubyzen::Parsers::ASTParser.instance.parse_file(f.path) + expect(result).to be_a(Rubyzen::Declarations::FileDeclaration) + expect(result.classes.first.name).to eq('Foo') + end + end + + it 'returns nil for unparseable Ruby' do + Tempfile.create(['invalid', '.rb']) do |f| + f.write('def class end end end {{{') + f.flush + + result = Rubyzen::Parsers::ASTParser.instance.parse_file(f.path) + expect(result).to be_nil + end + end + end +end diff --git a/spec/project_spec.rb b/spec/project_spec.rb new file mode 100644 index 0000000..de269b9 --- /dev/null +++ b/spec/project_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Project do + let(:fixtures_path) { File.expand_path('../fixtures', __FILE__) } + + describe '#files' do + it 'returns a FileCollection of all .rb files' do + project = Rubyzen::Project.new(fixtures_path) + files = project.files + expect(files).to be_a(Rubyzen::Collections::FileCollection) + expect(files.size).to be >= 2 + end + + it 'supports path filtering via with_paths' do + project = Rubyzen::Project.new(fixtures_path) + controllers = project.files.with_paths('controllers/') + expect(controllers.size).to eq(1) + expect(controllers.first.name).to eq('sample_controller.rb') + end + + it 'supports path exclusion via without_paths' do + project = Rubyzen::Project.new(fixtures_path) + non_controllers = project.files.without_paths('controllers/') + expect(non_controllers.map(&:name)).not_to include('sample_controller.rb') + end + end + + describe '#classes' do + it 'returns a ClassesCollection of all classes across files' do + project = Rubyzen::Project.new(fixtures_path) + classes = project.classes + expect(classes).to be_a(Rubyzen::Collections::ClassesCollection) + expect(classes.map(&:name_without_modules)).to include('SampleController', 'SampleModel') + end + end + + describe '#modules' do + it 'returns a ModulesCollection of all modules across files' do + project = Rubyzen::Project.new(fixtures_path) + modules = project.modules + expect(modules).to be_a(Rubyzen::Collections::ModulesCollection) + expect(modules.map(&:name)).to include('Controllers') + end + end + + describe 'with multiple paths' do + it 'accepts an array of paths' do + controllers_path = File.join(fixtures_path, 'controllers') + models_path = File.join(fixtures_path, 'models') + project = Rubyzen::Project.new([controllers_path, models_path]) + expect(project.files.size).to eq(2) + end + end +end diff --git a/spec/rspec/rspec_config_spec.rb b/spec/rspec/rspec_config_spec.rb new file mode 100644 index 0000000..e59ce6e --- /dev/null +++ b/spec/rspec/rspec_config_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +RSpec.describe 'RSpec expect override' do + it 'raises ArgumentError for non-collection subjects when loaded' do + script = <<~'SCRIPT' + require "rspec" + require "rubyzen" + require "rubyzen/rspec/rspec_config" + include RSpec::Matchers + begin + expect("not a collection").to eq("not a collection") + puts "NO_ERROR" + rescue ArgumentError => e + puts "RAISED: #{e.message}" + end + SCRIPT + + result = `RUBYZEN_PROJECT_PATHS=/tmp bundle exec ruby -I lib -e '#{script.gsub("'", "'\\\\''")}' 2>&1` + expect(result).to include('RAISED:') + expect(result).to include('Invalid subject') + end + + it 'allows Rubyzen collection subjects when loaded' do + script = <<~'SCRIPT' + require "rspec" + require "rubyzen" + require "rubyzen/rspec/rspec_config" + include RSpec::Matchers + begin + collection = Rubyzen::Collections::ClassesCollection.new + expect(collection).to be_empty + puts "OK" + rescue => e + puts "ERROR: #{e.message}" + end + SCRIPT + + result = `RUBYZEN_PROJECT_PATHS=/tmp bundle exec ruby -I lib -e '#{script.gsub("'", "'\\\\''")}' 2>&1` + expect(result).to include('OK') + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..0d18dbf --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,6 @@ +require 'rubyzen' +require_relative 'support/parse_helper' + +RSpec.configure do |config| + config.formatter = :documentation +end diff --git a/spec/support/parse_helper.rb b/spec/support/parse_helper.rb new file mode 100644 index 0000000..36ed56d --- /dev/null +++ b/spec/support/parse_helper.rb @@ -0,0 +1,12 @@ +require 'rubocop-ast' + +module ParseHelper + def parse_ruby(source, file_path: 'test.rb') + processed = RuboCop::AST::ProcessedSource.new(source, RUBY_VERSION.to_f, file_path) + Rubyzen::Declarations::FileDeclaration.new(file_path, processed.ast) + end +end + +RSpec.configure do |config| + config.include ParseHelper +end diff --git a/target_project b/target_project deleted file mode 160000 index b8decd6..0000000 --- a/target_project +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b8decd68066de69939f42e747cb2aacf7f327bc7