diff --git a/CHANGELOG.md b/CHANGELOG.md index 2367756..b38dc24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [0.3.0] + +### Added + +- **`ExpressionDeclaration`** — a value-expression primitive wrapping any AST node, with + predicates (`constant?`, `local_variable?`, `method_call?`, `constructor?`, `hash_literal?`, + `symbol?`, `string?`) and accessors (`constant_name`, `method_name`, `name`). +- **`#returns`** on `MethodDeclaration` and `BlockDeclaration` — the points at which it yields a + value (the implicit final expression plus explicit `return`s) as `ReturnDeclaration`s + (`#explicit?`, `#implicit?`, `#expression`), collected in a `ReturnsCollection` (`#expressions`). + `#return_expressions` remains as a shortcut for `returns.expressions` — an `ExpressionsCollection` + (`#hash_literals`, `#constants`). Both are bridged on `MethodsCollection` and `BlocksCollection`. +- **`CallSiteDeclaration#arguments`** — the call's arguments as an `ArgumentsCollection` + (a subclass of `ExpressionsCollection`, so it keeps `#hash_literals`/`#constants`). +- **`CallSiteDeclaration#receiver_expression`** — the receiver modeled structurally (constant / + constructor / local variable). `#receiver` (String const-name) is unchanged. +- **`CallSiteDeclaration#enclosing_blocks`** — the chain of enclosing `do..end`/`{ }` blocks + (innermost first), as a `BlocksCollection`. +- **`#assignments`** on `MethodDeclaration` and `BlockDeclaration` — local-variable assignments + (`AssignmentDeclaration`, with `#name` and `#value`) as an `AssignmentsCollection`. Bridged on + `MethodsCollection` and `BlocksCollection`. + +All additions are backward-compatible; no existing API changed. + ## [0.2.0] ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 2b1a6e3..eb3fd07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,12 @@ Project │ ├── .all_methods → MethodsCollection │ │ ├── .parameters → ParametersCollection │ │ ├── .call_sites → CallSiteCollection + │ │ │ └── (each) .arguments → ArgumentsCollection + │ │ │ .receiver_expression → ExpressionDeclaration + │ │ │ .enclosing_blocks → BlocksCollection + │ │ ├── .returns → ReturnsCollection + │ │ │ └── .expressions → ExpressionsCollection (also via .return_expressions) + │ │ ├── .assignments → AssignmentsCollection │ │ ├── .if_statements → DeclarationCollection │ │ ├── .rescues → RescuesCollection │ │ └── .raises → RaisesCollection @@ -52,7 +58,10 @@ Project ├── .modules → ModulesCollection ├── .call_sites → CallSiteCollection ├── .blocks → BlocksCollection - │ └── .call_sites → CallSiteCollection + │ ├── .call_sites → CallSiteCollection + │ ├── .returns → ReturnsCollection + │ │ └── .expressions → ExpressionsCollection (also via .return_expressions) + │ └── .assignments → AssignmentsCollection ├── .constants → ConstantsCollection └── .requires → RequiresCollection ``` @@ -221,9 +230,12 @@ Each declaration wraps an AST node and exposes domain-specific methods: |---|---|---| | `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 | +| `MethodDeclaration` | `name`, `parameters`, `parameters?`, `returns`, `return_expressions`, `assignments` | FilePathProvider, ClassNameProvider, LinesOfCodeProvider, CallSiteProvider, BlocksProvider, IfStatementsProvider, ConstantsProvider, VisibilityProvider, RescuesProvider, RaisesProvider, ReturnsProvider, AssignmentsProvider | +| `CallSiteDeclaration` | `name`, `receiver`, `receiver_expression`, `method_name`, `arguments`, `enclosing_blocks`, `keyword_args`, `keyword_arg_value_pairs`, `symbols`, `strings` | FilePathProvider, LineNumberProvider, ClassNameProvider, SourceCodeProvider, ArgumentsProvider, EnclosingBlocksProvider | +| `ExpressionDeclaration` | `name`, `constant?`, `local_variable?`, `method_call?`, `constructor?`, `hash_literal?`, `symbol?`, `string?`, `constant_name`, `method_name` | FilePathProvider, LineNumberProvider, ClassNameProvider, SourceCodeProvider | +| `AssignmentDeclaration` | `name`, `value` | FilePathProvider, LineNumberProvider, ClassNameProvider, SourceCodeProvider | +| `ReturnDeclaration` | `explicit?`, `implicit?`, `expression` | FilePathProvider, LineNumberProvider, ClassNameProvider, SourceCodeProvider | +| `BlockDeclaration` | `name`, `method_name`, `returns`, `return_expressions`, `assignments` | FilePathProvider, LineNumberProvider, ClassNameProvider, LinesOfCodeProvider, SourceCodeProvider, CallSiteProvider, RescuesProvider, RaisesProvider, ReturnsProvider, AssignmentsProvider | | `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 | diff --git a/lib/rubyzen/collections/arguments_collection.rb b/lib/rubyzen/collections/arguments_collection.rb new file mode 100644 index 0000000..a513c84 --- /dev/null +++ b/lib/rubyzen/collections/arguments_collection.rb @@ -0,0 +1,16 @@ +module Rubyzen + module Collections + # Collection of {Rubyzen::Declarations::ExpressionDeclaration} representing the arguments + # passed at a call site. + # + # A specialization of {ExpressionsCollection}: arguments are value-expressions, so this + # inherits the value-expression filters (`#hash_literals`, `#constants`) and remains a + # drop-in `ExpressionsCollection`. It exists as a distinct type so argument-specific + # filters (e.g. positional vs keyword) can be added later without a breaking change. + # + # @example + # call_site.arguments.first.constant_name + class ArgumentsCollection < ExpressionsCollection + end + end +end diff --git a/lib/rubyzen/collections/assignments_collection.rb b/lib/rubyzen/collections/assignments_collection.rb new file mode 100644 index 0000000..31409ab --- /dev/null +++ b/lib/rubyzen/collections/assignments_collection.rb @@ -0,0 +1,11 @@ +module Rubyzen + module Collections + # Collection of {Rubyzen::Declarations::AssignmentDeclaration} (local-variable assignments). + # + # @example + # method.assignments.with_name('user') + class AssignmentsCollection < BaseCollection + include Rubyzen::Providers::CollectionFilterProvider + end + end +end diff --git a/lib/rubyzen/collections/blocks_collection.rb b/lib/rubyzen/collections/blocks_collection.rb index 84a0865..6d12580 100644 --- a/lib/rubyzen/collections/blocks_collection.rb +++ b/lib/rubyzen/collections/blocks_collection.rb @@ -22,6 +22,27 @@ def call_sites all_call_sites = flat_map(&:call_sites) CallSiteCollection.new(all_call_sites) end + + # Returns all return points across every block. + # + # @return [ReturnsCollection] + def returns + ReturnsCollection.new(flat_map(&:returns)) + end + + # Returns all return expressions across every block. + # + # @return [ExpressionsCollection] + def return_expressions + returns.expressions + end + + # Returns all local-variable assignments across every block. + # + # @return [AssignmentsCollection] + def assignments + AssignmentsCollection.new(flat_map(&:assignments)) + end end end end diff --git a/lib/rubyzen/collections/expressions_collection.rb b/lib/rubyzen/collections/expressions_collection.rb new file mode 100644 index 0000000..ca2460c --- /dev/null +++ b/lib/rubyzen/collections/expressions_collection.rb @@ -0,0 +1,27 @@ +module Rubyzen + module Collections + # Collection of {Rubyzen::Declarations::ExpressionDeclaration} — return values, + # call arguments, and other value-expressions. + # + # @example + # method.return_expressions.hash_literals + class ExpressionsCollection < BaseCollection + include Rubyzen::Providers::CollectionFilterProvider + + # Filters to only braced Hash-literal expressions. + # + # @return [ExpressionsCollection] + def hash_literals + filter(&:hash_literal?) + end + + # Filters to only constant expressions, including constructors of a constant + # (e.g. both +Repos::Foo+ and +Repos::Foo.new+). + # + # @return [ExpressionsCollection] + def constants + filter(&:constant_name) + end + end + end +end diff --git a/lib/rubyzen/collections/methods_collection.rb b/lib/rubyzen/collections/methods_collection.rb index 864b466..aec253e 100644 --- a/lib/rubyzen/collections/methods_collection.rb +++ b/lib/rubyzen/collections/methods_collection.rb @@ -62,6 +62,35 @@ def raises end ) end + + # Returns all return points across every method. + # + # @return [ReturnsCollection] + def returns + ReturnsCollection.new( + flat_map do |method| + method.returns + end + ) + end + + # Returns all return expressions across every method. + # + # @return [ExpressionsCollection] + def return_expressions + returns.expressions + end + + # Returns all local-variable assignments across every method. + # + # @return [AssignmentsCollection] + def assignments + AssignmentsCollection.new( + flat_map do |method| + method.assignments + end + ) + end end end end diff --git a/lib/rubyzen/collections/returns_collection.rb b/lib/rubyzen/collections/returns_collection.rb new file mode 100644 index 0000000..354985f --- /dev/null +++ b/lib/rubyzen/collections/returns_collection.rb @@ -0,0 +1,20 @@ +module Rubyzen + module Collections + # Collection of {Rubyzen::Declarations::ReturnDeclaration} — the points at which a + # method or block yields a value. + # + # @example + # method.returns.expressions.hash_literals + class ReturnsCollection < BaseCollection + include Rubyzen::Providers::CollectionFilterProvider + + # The value expressions of every return. Bare +return+s (which have no value) + # are omitted. + # + # @return [ExpressionsCollection] + def expressions + ExpressionsCollection.new(filter_map(&:expression)) + end + end + end +end diff --git a/lib/rubyzen/declarations/assignment_declaration.rb b/lib/rubyzen/declarations/assignment_declaration.rb new file mode 100644 index 0000000..b809f50 --- /dev/null +++ b/lib/rubyzen/declarations/assignment_declaration.rb @@ -0,0 +1,57 @@ +module Rubyzen + module Declarations + # Represents a local-variable assignment (an +lvasgn+ node), e.g. +x = Repos::Foo.new+. + # + # @example + # assignment = method.assignments.first + # assignment.name #=> "x" + # assignment.value.constructor? #=> true + # assignment.value.constant_name #=> "Repos::Foo" + # + # NOTE: Multiple assignment (+a, b = ...+) is only partially modelled. Each target is + # surfaced as its own AssignmentDeclaration with a correct {#name}, but {#value} is +nil+: + # in the AST the right-hand side lives on the enclosing +masgn+ node, not on the per-target + # +lvasgn+, and which value each variable receives generally cannot be known statically + # (e.g. +a, b = build_pair+). If a rule ever needs to trace destructured assignments, model + # the shared source explicitly (a +multiple_assignment?+ predicate + +shared_source+) rather + # than attributing the shared right-hand side to each variable's {#value}. + # + class AssignmentDeclaration + include Rubyzen::Providers::FilePathProvider + include Rubyzen::Providers::LineNumberProvider + include Rubyzen::Providers::ClassNameProvider + include Rubyzen::Providers::SourceCodeProvider + + # @return [RuboCop::AST::Node] + attr_reader :node + + # @return [MethodDeclaration, BlockDeclaration] + attr_reader :parent + + # @param node [RuboCop::AST::Node] the +lvasgn+ node + # @param parent [MethodDeclaration, BlockDeclaration] the enclosing declaration + def initialize(node, parent) + @node = node + @parent = parent + end + + # Returns the name of the assigned local variable. + # + # @return [String] e.g. +"x"+ + def name + node.children.first.to_s + end + + # Returns the assigned value as an expression, or +nil+ when there is no value + # node (e.g. the per-variable targets of a multiple assignment, +a, b = foo+). + # + # @return [ExpressionDeclaration, nil] + def value + value_node = node.children[1] + return nil if value_node.nil? + + ExpressionDeclaration.new(value_node, self) + end + end + end +end diff --git a/lib/rubyzen/declarations/block_declaration.rb b/lib/rubyzen/declarations/block_declaration.rb index 0c71d4f..53c170a 100644 --- a/lib/rubyzen/declarations/block_declaration.rb +++ b/lib/rubyzen/declarations/block_declaration.rb @@ -17,6 +17,8 @@ class BlockDeclaration include Rubyzen::Providers::RaisesProvider include Rubyzen::Providers::SourceCodeProvider include Rubyzen::Providers::CallSiteProvider + include Rubyzen::Providers::ReturnsProvider + include Rubyzen::Providers::AssignmentsProvider # @return [RuboCop::AST::Node] attr_reader :node diff --git a/lib/rubyzen/declarations/call_site_declaration.rb b/lib/rubyzen/declarations/call_site_declaration.rb index 6fa1f80..eaafa01 100644 --- a/lib/rubyzen/declarations/call_site_declaration.rb +++ b/lib/rubyzen/declarations/call_site_declaration.rb @@ -13,6 +13,8 @@ class CallSiteDeclaration include Rubyzen::Providers::LineNumberProvider include Rubyzen::Providers::ClassNameProvider include Rubyzen::Providers::SourceCodeProvider + include Rubyzen::Providers::ArgumentsProvider + include Rubyzen::Providers::EnclosingBlocksProvider # @return [RuboCop::AST::Node] attr_reader :node @@ -41,6 +43,18 @@ def receiver node.receiver&.type == :const ? node.receiver.const_name : nil end + # Returns the receiver as an expression, or +nil+ for a receiverless call (e.g. +save+). + # + # Unlike {#receiver} (which returns a constant-name String), this models the receiver + # structurally — constant, constructor, or local variable. + # + # @return [Rubyzen::Declarations::ExpressionDeclaration, nil] + def receiver_expression + return nil if node.receiver.nil? + + ExpressionDeclaration.new(node.receiver, self) + end + # Returns the called method name. # # @return [String] diff --git a/lib/rubyzen/declarations/expression_declaration.rb b/lib/rubyzen/declarations/expression_declaration.rb new file mode 100644 index 0000000..78fc719 --- /dev/null +++ b/lib/rubyzen/declarations/expression_declaration.rb @@ -0,0 +1,99 @@ +module Rubyzen + module Declarations + # Represents an arbitrary Ruby value-expression node — the value a method returns, + # the receiver of a call, a positional argument, the value of an assignment, and so on. + # Wraps any AST node and exposes its "kind" through predicates, so rules can ask + # structural questions without touching the raw AST. + # + # @example + # expr = call_site.receiver_expression + # expr.constructor? #=> true (for Repos::Foo.new.bar) + # expr.constant_name #=> "Repos::Foo" + # + class ExpressionDeclaration + include Rubyzen::Providers::FilePathProvider + include Rubyzen::Providers::LineNumberProvider + include Rubyzen::Providers::ClassNameProvider + include Rubyzen::Providers::SourceCodeProvider + + # @return [RuboCop::AST::Node] + attr_reader :node + + # @return [Object] the declaration that produced this expression + attr_reader :parent + + # @param node [RuboCop::AST::Node] the value-expression node + # @param parent [Object] the declaration that produced this expression + def initialize(node, parent) + @node = node + @parent = parent + end + + # Returns a short identifier: the constant, variable, or method name, falling + # back to the node type. + # + # @return [String] + def name + constant_name || local_variable_name || (method_call? ? method_name : node.type.to_s) + end + + # @return [Boolean] true if the expression is a bare constant, e.g. +Repos::Foo+ + def constant? + node.const_type? + end + + # @return [Boolean] true if the expression references a local variable + def local_variable? + node.lvar_type? + end + + # @return [Boolean] true if the expression is a method call (a +send+ node) + def method_call? + node.send_type? + end + + # @return [Boolean] true if the expression is a constructor call, e.g. +Repos::Foo.new+ + def constructor? + method_call? && node.method_name == :new + end + + # @return [Boolean] true if the expression is a braced Hash literal with at least one pair + def hash_literal? + node.hash_type? && node.braces? && node.pairs.any? + end + + # @return [Boolean] true if the expression is a symbol literal + def symbol? + node.sym_type? + end + + # @return [Boolean] true if the expression is a string literal + def string? + node.str_type? + end + + # Returns the constant name when the expression is a constant or constructs from one. + # + # @return [String, nil] e.g. +"Repos::Foo"+ for both +Repos::Foo+ and +Repos::Foo.new+ + def constant_name + return node.const_name if constant? + return node.receiver.const_name if constructor? && node.receiver&.const_type? + + nil + end + + # Returns the called method name when the expression is a method call. + # + # @return [String, nil] + def method_name + node.method_name.to_s if method_call? + end + + private + + def local_variable_name + node.children.first.to_s if local_variable? + end + end + end +end diff --git a/lib/rubyzen/declarations/method_declaration.rb b/lib/rubyzen/declarations/method_declaration.rb index 370e395..639d79b 100644 --- a/lib/rubyzen/declarations/method_declaration.rb +++ b/lib/rubyzen/declarations/method_declaration.rb @@ -21,6 +21,8 @@ class MethodDeclaration include Rubyzen::Providers::VisibilityProvider include Rubyzen::Providers::RescuesProvider include Rubyzen::Providers::RaisesProvider + include Rubyzen::Providers::ReturnsProvider + include Rubyzen::Providers::AssignmentsProvider # @return [RuboCop::AST::Node] attr_reader :node diff --git a/lib/rubyzen/declarations/return_declaration.rb b/lib/rubyzen/declarations/return_declaration.rb new file mode 100644 index 0000000..0974356 --- /dev/null +++ b/lib/rubyzen/declarations/return_declaration.rb @@ -0,0 +1,51 @@ +module Rubyzen + module Declarations + # Represents a single point at which a method or block yields a value: the + # implicit final expression of its body, or an explicit +return+ statement. + # + # @example + # ret = method.returns.first + # ret.explicit? #=> false + # ret.expression.hash_literal? #=> true + # + class ReturnDeclaration + include Rubyzen::Providers::FilePathProvider + include Rubyzen::Providers::LineNumberProvider + include Rubyzen::Providers::ClassNameProvider + include Rubyzen::Providers::SourceCodeProvider + + # @return [RuboCop::AST::Node] the +return+ node, or the implicit final-expression node + attr_reader :node + + # @return [MethodDeclaration, BlockDeclaration] the declaration that returns this value + attr_reader :parent + + # @param node [RuboCop::AST::Node] the +return+ node, or the implicit final-expression node + # @param parent [MethodDeclaration, BlockDeclaration] the enclosing declaration + def initialize(node, parent) + @node = node + @parent = parent + end + + # @return [Boolean] true if this is an explicit +return+ statement + def explicit? + node.return_type? + end + + # @return [Boolean] true if this is the implicit final expression of the body + def implicit? + !explicit? + end + + # The value expression being returned. + # + # @return [ExpressionDeclaration, nil] +nil+ for a bare +return+ with no value + def expression + value_node = explicit? ? node.children.first : node + return nil if value_node.nil? + + ExpressionDeclaration.new(value_node, self) + end + end + end +end diff --git a/lib/rubyzen/providers/arguments_provider.rb b/lib/rubyzen/providers/arguments_provider.rb new file mode 100644 index 0000000..cd372db --- /dev/null +++ b/lib/rubyzen/providers/arguments_provider.rb @@ -0,0 +1,15 @@ +module Rubyzen + module Providers + # Provides the arguments passed at a call site (or macro), as expressions. + module ArgumentsProvider + # @return [Rubyzen::Collections::ArgumentsCollection] + def arguments + Collections::ArgumentsCollection.new( + node.arguments.map do |argument_node| + Declarations::ExpressionDeclaration.new(argument_node, self) + end + ) + end + end + end +end diff --git a/lib/rubyzen/providers/assignments_provider.rb b/lib/rubyzen/providers/assignments_provider.rb new file mode 100644 index 0000000..a4308e6 --- /dev/null +++ b/lib/rubyzen/providers/assignments_provider.rb @@ -0,0 +1,15 @@ +module Rubyzen + module Providers + # Provides local-variable assignments within a method or block. + module AssignmentsProvider + # @return [Rubyzen::Collections::AssignmentsCollection] + def assignments + Collections::AssignmentsCollection.new( + node.each_descendant(:lvasgn).map do |assignment_node| + Declarations::AssignmentDeclaration.new(assignment_node, self) + end + ) + end + end + end +end diff --git a/lib/rubyzen/providers/enclosing_blocks_provider.rb b/lib/rubyzen/providers/enclosing_blocks_provider.rb new file mode 100644 index 0000000..15700a9 --- /dev/null +++ b/lib/rubyzen/providers/enclosing_blocks_provider.rb @@ -0,0 +1,16 @@ +module Rubyzen + module Providers + # Provides the chain of blocks (do..end / { }) that lexically enclose a declaration, + # innermost first. + module EnclosingBlocksProvider + # @return [Rubyzen::Collections::BlocksCollection] + def enclosing_blocks + Collections::BlocksCollection.new( + node.each_ancestor(:block).map do |block_node| + Declarations::BlockDeclaration.new(block_node, parent) + end + ) + end + end + end +end diff --git a/lib/rubyzen/providers/returns_provider.rb b/lib/rubyzen/providers/returns_provider.rb new file mode 100644 index 0000000..5f4b5fc --- /dev/null +++ b/lib/rubyzen/providers/returns_provider.rb @@ -0,0 +1,49 @@ +module Rubyzen + module Providers + # Provides the points at which a method or block yields a value: the implicit final + # expression of its body, plus every explicit +return+. Each is wrapped in a + # {Rubyzen::Declarations::ReturnDeclaration}, which knows how to extract its own value. + module ReturnsProvider + # @return [Rubyzen::Collections::ReturnsCollection] + def returns + Collections::ReturnsCollection.new( + return_nodes.map do |return_node| + Declarations::ReturnDeclaration.new(return_node, self) + end + ) + end + + # The value-expression(s) this method or block evaluates to, as a flat collection. + # A shortcut for +returns.expressions+. + # + # @return [Rubyzen::Collections::ExpressionsCollection] + def return_expressions + returns.expressions + end + + private + + # The return points to wrap: the implicit final expression of the body (unless it is + # itself an explicit +return+, which is collected separately to avoid double-counting), + # followed by every explicit +return+. + def return_nodes + nodes = [] + final = implicit_final_node + nodes << final unless final.nil? + node.each_descendant(:return) { |return_node| nodes << return_node } + nodes + end + + # The node a body implicitly evaluates to: the last statement of a multi-statement + # body (+begin+) or an explicit +begin..end+ (+kwbegin+), otherwise the body itself. + # Returns +nil+ when that node is an explicit +return+ (collected separately). + def implicit_final_node + body = node.body + return nil if body.nil? + + final = body.begin_type? || body.kwbegin_type? ? body.children.last : body + final unless final.return_type? + end + end + end +end diff --git a/lib/rubyzen/version.rb b/lib/rubyzen/version.rb index 750d523..17e47ce 100644 --- a/lib/rubyzen/version.rb +++ b/lib/rubyzen/version.rb @@ -1,4 +1,4 @@ module Rubyzen # @return [String] the current gem version - VERSION = '0.2.0' + VERSION = '0.3.0' end diff --git a/sample_project/spec/core/data_methods_return_data_objects_lint_spec.rb b/sample_project/spec/core/data_methods_return_data_objects_lint_spec.rb new file mode 100644 index 0000000..0f5cf8b --- /dev/null +++ b/sample_project/spec/core/data_methods_return_data_objects_lint_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'Methods named _data or _dto return Data objects, not Hash literals' do + context 'given source classes' do + it 'does not return a Hash literal as the final expression' do + expect( + all_classes.all_methods + .filter { |m| m.public? && m.name&.end_with?('_data', '_dto') } + .filter { |m| m.return_expressions.hash_literals.any? } + ).to zen_empty + end + end +end diff --git a/sample_project/spec/questions/no_side_effects_in_questions_lint_spec.rb b/sample_project/spec/questions/no_side_effects_in_questions_lint_spec.rb new file mode 100644 index 0000000..c96c94a --- /dev/null +++ b/sample_project/spec/questions/no_side_effects_in_questions_lint_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'Questions should not have side effects' do + let(:write_prefixes) { %w[create update delete destroy save] } + + let(:side_effect_call_sites) do + questions.all_methods.call_sites.filter do |cs| + write_call?(cs) && (constructor_receiver_repo?(cs) || local_variable_receiver_repo?(cs)) + end + end + + def write_call?(call_site) + write_prefixes.any? { |prefix| call_site.method_name.start_with?(prefix) } + end + + def constructor_receiver_repo?(call_site) + receiver = call_site.receiver_expression + receiver&.constructor? && receiver.constant_name&.start_with?('Repos::') + end + + def local_variable_receiver_repo?(call_site) + receiver = call_site.receiver_expression + return false unless receiver&.local_variable? + + repo_locals(call_site.parent).include?(receiver.name) + end + + def repo_locals(method_declaration) + return [] unless method_declaration + + method_declaration.assignments.filter_map do |assignment| + value = assignment.value + next unless value&.constructor? + next unless value.constant_name&.start_with?('Repos::') + + assignment.name + end + end + + context 'given question classes' do + it 'does not call write methods on Repos' do + expect(side_effect_call_sites).to zen_empty + end + end +end diff --git a/sample_project/spec/spec_helper.rb b/sample_project/spec/spec_helper.rb index 2e1c0c0..9ab3fda 100644 --- a/sample_project/spec/spec_helper.rb +++ b/sample_project/spec/spec_helper.rb @@ -16,6 +16,7 @@ let(:services) { project.files.with_paths('src/services/').classes } let(:jobs) { project.files.with_paths('src/jobs/').classes } let(:requests) { project.files.with_paths('src/requests/').classes } + let(:questions) { project.files.with_paths('src/questions/').classes } let(:models_files) { project.files.with_paths('src/models/') } let(:models) { models_files.classes } diff --git a/sample_project/spec/tests/admin_calls_use_with_admin_context_lint_spec.rb b/sample_project/spec/tests/admin_calls_use_with_admin_context_lint_spec.rb new file mode 100644 index 0000000..0c7e1df --- /dev/null +++ b/sample_project/spec/tests/admin_calls_use_with_admin_context_lint_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'Admin API calls must use with_admin_context' do + context 'given test files' do + let(:test_source_files) { project.files.with_paths('src/tests/') } + + let(:admin_calls) do + test_source_files.call_sites + .with_name('get') + .filter { |cs| cs.strings.any? { |path| path.start_with?('/admin/') } } + end + + it 'wraps admin calls in a with_admin_context block' do + expect(admin_calls).to zen_true { |cs| + cs.enclosing_blocks.with_name('with_admin_context').any? + } + end + end +end diff --git a/sample_project/spec/tests/no_stubbing_repos_lint_spec.rb b/sample_project/spec/tests/no_stubbing_repos_lint_spec.rb new file mode 100644 index 0000000..7f21e7d --- /dev/null +++ b/sample_project/spec/tests/no_stubbing_repos_lint_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'Do not stub core domain classes (Repos) in tests' do + context 'given test files' do + let(:test_source_files) { project.files.with_paths('src/tests/') } + let(:stub_calls) { test_source_files.call_sites.with_name('allow') } + + it 'does not stub Repos constants' do + expect(stub_calls).to zen_false { |cs| + cs.arguments.first&.constant_name&.start_with?('Repos::') + } + end + end +end diff --git a/sample_project/src/questions/profile_status.rb b/sample_project/src/questions/profile_status.rb new file mode 100644 index 0000000..d79aa77 --- /dev/null +++ b/sample_project/src/questions/profile_status.rb @@ -0,0 +1,16 @@ +module Questions + class ProfileStatus + def reset? + Repos::Profiles.new.delete(profile_id) + end + + def suspend? + repo = Repos::Profiles.new + repo.update(profile_id, suspended: true) + end + + def active? + Repos::Profiles.new.find(profile_id) + end + end +end diff --git a/sample_project/src/serializers/user_serializer.rb b/sample_project/src/serializers/user_serializer.rb new file mode 100644 index 0000000..4eeb0e0 --- /dev/null +++ b/sample_project/src/serializers/user_serializer.rb @@ -0,0 +1,12 @@ +module Serializers + class UserSerializer + def user_data + log + { id: 1, name: 'Sample' } + end + + def user_dto + UserData.new(id: 1) + end + end +end diff --git a/sample_project/src/tests/admin_flow_test.rb b/sample_project/src/tests/admin_flow_test.rb new file mode 100644 index 0000000..53fcb67 --- /dev/null +++ b/sample_project/src/tests/admin_flow_test.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +RSpec.describe 'Admin flow' do + it 'lists users without admin context' do + get '/admin/users' + end + + it 'lists users with admin context' do + with_admin_context do + get '/admin/users' + end + end +end diff --git a/sample_project/src/tests/stubbing_test.rb b/sample_project/src/tests/stubbing_test.rb new file mode 100644 index 0000000..5cd4f6b --- /dev/null +++ b/sample_project/src/tests/stubbing_test.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.describe 'Stubbing repos' do + before do + allow(Repos::User).to receive(:find).and_return(nil) + end + + it 'does something' do + expect(true).to be(true) + end +end diff --git a/spec/collections/arguments_collection_spec.rb b/spec/collections/arguments_collection_spec.rb new file mode 100644 index 0000000..d62d4df --- /dev/null +++ b/spec/collections/arguments_collection_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::ArgumentsCollection do + def arguments_of(source, name) + parse_ruby("#{source}\nsentinel = 1").call_sites.with_name(name).first.arguments + end + + it 'is the type returned by CallSiteDeclaration#arguments' do + expect(arguments_of('build(Repos::Foo)', 'build')).to be_a(described_class) + end + + it 'is a specialization of ExpressionsCollection (drop-in, non-breaking)' do + expect(arguments_of('build(Repos::Foo)', 'build')) + .to be_a(Rubyzen::Collections::ExpressionsCollection) + end + + it 'inherits the value-expression filters' do + args = arguments_of('build(Repos::Foo, { id: 1 })', 'build') + expect(args.constants.map(&:constant_name)).to eq(['Repos::Foo']) + expect(args.hash_literals).not_to be_empty + end + + it 'stays an ArgumentsCollection after filtering' do + args = arguments_of('build(Repos::Foo, other)', 'build') + expect(args.constants).to be_a(described_class) + end +end diff --git a/spec/collections/assignments_collection_spec.rb b/spec/collections/assignments_collection_spec.rb new file mode 100644 index 0000000..d70e70c --- /dev/null +++ b/spec/collections/assignments_collection_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::AssignmentsCollection do + def assignments_of(body) + file = parse_ruby(<<~RUBY) + class Q + def go + #{body} + end + end + RUBY + file.classes.first.instance_methods.first.assignments + end + + it '#with_name filters by variable name' do + assignments = assignments_of("user = build\n other = build") + expect(assignments.with_name('user').map(&:name)).to eq(['user']) + end + + it '#with_name returns the same collection type' do + assignments = assignments_of("user = build\n other = build") + expect(assignments.with_name('user')).to be_a(described_class) + end +end diff --git a/spec/collections/blocks_collection_spec.rb b/spec/collections/blocks_collection_spec.rb index 62ee8dc..db867dc 100644 --- a/spec/collections/blocks_collection_spec.rb +++ b/spec/collections/blocks_collection_spec.rb @@ -29,6 +29,15 @@ def bar end end + describe '#returns' do + it 'returns all return points across blocks' do + returns = blocks.returns + expect(returns).to be_a(Rubyzen::Collections::ReturnsCollection) + expect(returns).not_to be_empty + expect(returns).to all(be_a(Rubyzen::Declarations::ReturnDeclaration)) + end + end + describe 'CollectionFilterProvider' do it 'supports with_name' do result = blocks.with_name('each') diff --git a/spec/collections/expressions_collection_spec.rb b/spec/collections/expressions_collection_spec.rb new file mode 100644 index 0000000..7f63cc4 --- /dev/null +++ b/spec/collections/expressions_collection_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::ExpressionsCollection do + def return_expressions_of(body) + file = parse_ruby(<<~RUBY) + class Foo + def bar + #{body} + end + end + RUBY + file.classes.first.instance_methods.first.return_expressions + end + + it '#hash_literals keeps only hash-literal expressions' do + expressions = return_expressions_of("log\n { id: 1 }") + expect(expressions.hash_literals).not_to be_empty + expect(expressions.hash_literals).to all(satisfy(&:hash_literal?)) + end + + it '#constants keeps bare-constant expressions' do + expressions = return_expressions_of('SomeConstant') + expect(expressions.constants.map(&:constant_name)).to eq(['SomeConstant']) + end + + it '#constants also keeps constructor-of-constant expressions (e.g. Repos::Foo.new)' do + expressions = return_expressions_of('Repos::Foo.new') + expect(expressions.constants.map(&:constant_name)).to eq(['Repos::Foo']) + end + + it 'filter methods return the same collection type' do + expressions = return_expressions_of("log\n { id: 1 }") + expect(expressions.hash_literals).to be_a(described_class) + expect(expressions.constants).to be_a(described_class) + end +end diff --git a/spec/collections/methods_collection_spec.rb b/spec/collections/methods_collection_spec.rb index ef79567..95ea0f9 100644 --- a/spec/collections/methods_collection_spec.rb +++ b/spec/collections/methods_collection_spec.rb @@ -61,6 +61,15 @@ def baz end end + describe '#returns' do + it 'returns all return points across methods' do + returns = methods.returns + expect(returns).to be_a(Rubyzen::Collections::ReturnsCollection) + expect(returns).not_to be_empty + expect(returns).to all(be_a(Rubyzen::Declarations::ReturnDeclaration)) + end + end + describe 'CollectionFilterProvider' do it 'supports with_name' do result = methods.with_name('bar') diff --git a/spec/collections/returns_collection_spec.rb b/spec/collections/returns_collection_spec.rb new file mode 100644 index 0000000..88361e4 --- /dev/null +++ b/spec/collections/returns_collection_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Collections::ReturnsCollection do + def returns_of(body) + file = parse_ruby(<<~RUBY) + class Foo + def bar + #{body} + end + end + RUBY + file.classes.first.instance_methods.first.returns + end + + describe '#expressions' do + it 'returns an ExpressionsCollection' do + expect(returns_of('{ id: 1 }').expressions) + .to be_a(Rubyzen::Collections::ExpressionsCollection) + end + + it 'exposes the value expressions, filterable with #hash_literals' do + expressions = returns_of("log\n { id: 1 }").expressions + expect(expressions.hash_literals).not_to be_empty + end + + it 'omits bare returns that have no value' do + expressions = returns_of("return unless ready\n { id: 1 }").expressions + expect(expressions.size).to eq(1) + expect(expressions.first.hash_literal?).to be(true) + end + end + + it 'supports name-based filtering from CollectionFilterProvider' do + expect(returns_of('{ id: 1 }')).to respond_to(:with_name) + end +end diff --git a/spec/declarations/assignment_declaration_spec.rb b/spec/declarations/assignment_declaration_spec.rb new file mode 100644 index 0000000..6167975 --- /dev/null +++ b/spec/declarations/assignment_declaration_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::AssignmentDeclaration do + def method_from(body) + file = parse_ruby(<<~RUBY) + class Q + def go + #{body} + end + end + RUBY + file.classes.first.instance_methods.first + end + + it 'exposes the variable name and the constructor value' do + method = method_from("x = Repos::Foo.new\n x.create(1)") + assignment = method.assignments.first + expect(assignment.name).to eq('x') + expect(assignment.value.constructor?).to be(true) + expect(assignment.value.constant_name).to eq('Repos::Foo') + end + + it 'collects every local-variable assignment in the method' do + method = method_from("a = 1\n b = 2") + expect(method.assignments.map(&:name)).to contain_exactly('a', 'b') + end + + it 'returns a nil value for a multiple-assignment target (no value node)' do + method = method_from("a, b = build_pair\n a") + by_name = method.assignments.to_h { |assignment| [assignment.name, assignment] } + expect(by_name['a'].value).to be_nil + expect(by_name['b'].value).to be_nil + end + + describe 'inside a block' do + it 'collects block-local assignments, aggregated through the collection bridge' do + file = parse_ruby(<<~RUBY) + run do + repo = Repos::Foo.new + repo + end + sentinel = 1 + RUBY + assignment = file.blocks.assignments.first + expect(assignment.name).to eq('repo') + expect(assignment.value.constant_name).to eq('Repos::Foo') + end + end +end diff --git a/spec/declarations/call_site_declaration_spec.rb b/spec/declarations/call_site_declaration_spec.rb index 89ca3ac..481e307 100644 --- a/spec/declarations/call_site_declaration_spec.rb +++ b/spec/declarations/call_site_declaration_spec.rb @@ -104,4 +104,37 @@ def bar expect(site.class_name).to eq('Foo') end end + + describe '#receiver_expression' do + it 'models a constructor receiver and resolves its constant' do + site = call_sites_from('Repos::Foo.new.create(1)').with_name('create').first + expect(site.receiver_expression.constructor?).to be(true) + expect(site.receiver_expression.constant_name).to eq('Repos::Foo') + end + + it 'is nil for a receiverless call' do + site = call_sites_from('save').with_name('save').first + expect(site.receiver_expression).to be_nil + end + end + + describe '#arguments' do + it 'exposes a constant first argument' do + site = call_sites_from('allow(Repos::Foo)').with_name('allow').first + expect(site.arguments.first.constant_name).to eq('Repos::Foo') + end + end + + describe '#enclosing_blocks' do + it 'finds an enclosing block by name' do + sites = call_sites_from("with_admin_context do\n get '/admin/x'\n end") + site = sites.with_name('get').first + expect(site.enclosing_blocks.with_name('with_admin_context')).not_to be_empty + end + + it 'is empty when not nested in a matching block' do + site = call_sites_from("get '/admin/x'").with_name('get').first + expect(site.enclosing_blocks.with_name('with_admin_context')).to be_empty + end + end end diff --git a/spec/declarations/expression_declaration_spec.rb b/spec/declarations/expression_declaration_spec.rb new file mode 100644 index 0000000..27a2ece --- /dev/null +++ b/spec/declarations/expression_declaration_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::ExpressionDeclaration do + def first_call(source, name) + parse_ruby("#{source}\nsentinel = 1").call_sites.with_name(name).first + end + + def method_from(body) + file = parse_ruby(<<~RUBY) + class Foo + def bar + #{body} + end + end + RUBY + file.classes.first.instance_methods.first + end + + describe 'kind predicates via a call-site receiver' do + it 'recognizes a bare constant' do + expr = first_call('Repos::Foo.find(1)', 'find').receiver_expression + expect(expr.constant?).to be(true) + expect(expr.constant_name).to eq('Repos::Foo') + expect(expr.name).to eq('Repos::Foo') + end + + it 'recognizes a constructor (Const.new) and resolves its constant' do + expr = first_call('Repos::Foo.new.create(1)', 'create').receiver_expression + expect(expr.constructor?).to be(true) + expect(expr.method_call?).to be(true) + expect(expr.constant_name).to eq('Repos::Foo') + end + + it 'recognizes a local-variable receiver' do + file = parse_ruby("x = Repos::Foo.new\nx.create(1)") + expr = file.call_sites.with_name('create').first.receiver_expression + expect(expr.local_variable?).to be(true) + expect(expr.name).to eq('x') + end + end + + describe 'kind predicates via call-site arguments' do + it 'exposes a constant argument name' do + expr = first_call('allow(Repos::Foo)', 'allow').arguments.first + expect(expr.constant?).to be(true) + expect(expr.constant_name).to eq('Repos::Foo') + end + + it 'returns nil constant_name for a non-constant argument' do + expr = first_call('allow(thing)', 'allow').arguments.first + expect(expr.constant_name).to be_nil + end + end + + describe '#hash_literal? via a method return expression' do + it 'is true for a braced hash literal as the final expression' do + method = method_from("log\n { id: 1 }") + expect(method.return_expressions.hash_literals).not_to be_empty + end + + it 'is false for a Data constructor return' do + method = method_from('UserData.new(id: 1)') + expect(method.return_expressions.hash_literals).to be_empty + end + end + + describe '#hash_literal? via a block final expression' do + it 'detects a hash literal returned by a block, through the collection bridge' do + file = parse_ruby(<<~RUBY) + build do + log + { id: 1 } + end + sentinel = 1 + RUBY + expect(file.blocks.return_expressions.hash_literals).not_to be_empty + end + end +end diff --git a/spec/declarations/return_declaration_spec.rb b/spec/declarations/return_declaration_spec.rb new file mode 100644 index 0000000..b9af2b5 --- /dev/null +++ b/spec/declarations/return_declaration_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +RSpec.describe Rubyzen::Declarations::ReturnDeclaration do + def method_from(body) + file = parse_ruby(<<~RUBY) + class Foo + def bar + #{body} + end + end + RUBY + file.classes.first.instance_methods.first + end + + describe 'the implicit final expression' do + it 'wraps the body itself for a single-statement method' do + returns = method_from('{ id: 1 }').returns + expect(returns.size).to eq(1) + expect(returns.first.implicit?).to be(true) + expect(returns.first.explicit?).to be(false) + expect(returns.first.expression.hash_literal?).to be(true) + end + + it 'wraps the last statement of a multi-statement body' do + returns = method_from("log\n { id: 1 }").returns + expect(returns.size).to eq(1) + expect(returns.first.expression.hash_literal?).to be(true) + end + + it 'wraps the last statement inside an explicit begin..end (kwbegin) body' do + returns = method_from("begin\n log\n { id: 1 }\n end").returns + expect(returns.size).to eq(1) + expect(returns.first.expression.hash_literal?).to be(true) + end + end + + describe 'an explicit return' do + it 'unwraps the returned value and never surfaces the return node itself' do + returns = method_from('return { id: 1 }').returns + expect(returns.size).to eq(1) + expect(returns.first.explicit?).to be(true) + expect(returns.first.expression.hash_literal?).to be(true) + end + + it 'is counted once when it is also the final statement of the body' do + returns = method_from("log\n return { id: 1 }").returns + expect(returns.size).to eq(1) + expect(returns.first.explicit?).to be(true) + expect(returns.first.expression.hash_literal?).to be(true) + end + + it 'has a nil expression for a bare return' do + returns = method_from('return').returns + expect(returns.size).to eq(1) + expect(returns.first.explicit?).to be(true) + expect(returns.first.expression).to be_nil + end + end +end