Documentation/Buki/Ameba/ skills /ameba-custom-rules

📖 ameba-custom-rules

Use when creating custom Ameba rules for Crystal code analysis including rule development, AST traversal, issue reporting, and rule testing.



Overview

Create custom linting rules for Ameba to enforce project-specific code quality standards and catch domain-specific code smells in Crystal projects.

Understanding Custom Rules

Custom Ameba rules allow you to:

  • Enforce project-specific coding standards
  • Catch domain-specific anti-patterns
  • Validate business logic constraints
  • Ensure consistency across large codebases
  • Create reusable rule libraries for your organization
  • Extend Ameba's built-in capabilities

Rule Anatomy

Basic Rule Structure

Every Ameba rule inherits from Ameba::Rule::Base and follows this structure:

module Ameba::Rule::Custom
  # Rule that enforces documentation on public classes
  class DocumentedClasses < Base
    properties do
      description "Enforces public classes to be documented"
    end

    MSG = "Class must be documented with a comment"

    def test(source)
      AST::NodeVisitor.new self, source
    end

    def test(source, node : Crystal::ClassDef)
      return unless node.visibility.public?

      doc = node.doc
      issue_for(node, MSG) if doc.nil? || doc.empty?
    end
  end
end

Key Components

  1. Module namespace - Custom rules typically use Ameba::Rule::Custom or Ameba::Rule::<Category>
  2. Base class - All rules inherit from Ameba::Rule::Base
  3. Properties block - Defines rule metadata and configuration
  4. Message constant - The error message shown to users
  5. Test method - Entry point that initializes the AST visitor
  6. Overloaded test methods - Handle specific AST node types

Creating Your First Custom Rule

Step 1: Project Setup

Create a Crystal library for your custom rules:

# Initialize a new Crystal library
crystal init lib ameba-custom-rules
cd ameba-custom-rules

Update shard.yml:

name: ameba-custom-rules
version: 0.1.0
authors:
  - Your Name <your.email@example.com>

description: Custom Ameba rules for your project

crystal: ">= 1.0.0"

license: MIT

development_dependencies:
  ameba:
    github: crystal-ameba/ameba
    version: ~> 1.6.0

Important: Ameba should be a development dependency to avoid version conflicts.

Step 2: Implement a Simple Rule

Create src/ameba-custom-rules/no_sleep_in_production.cr:

require "ameba"

module Ameba::Rule::Custom
  # Prevents sleep() calls in production code
  class NoSleepInProduction < Base
    properties do
      description "Prevents sleep calls in production code"
      enabled true
    end

    MSG = "Avoid using sleep() in production code; use proper background jobs"

    def test(source)
      AST::NodeVisitor.new self, source
    end

    def test(source, node : Crystal::Call)
      return unless node.name == "sleep"

      issue_for node, MSG
    end
  end
end

Step 3: Register and Use the Rule

Create main file src/ameba-custom-rules.cr:

require "ameba"
require "./ameba-custom-rules/*"

# Rules are automatically registered through inheritance

Update your project's Ameba configuration:

# .ameba.yml
Custom/NoSleepInProduction:
  Enabled: true
  Severity: Warning

Step 4: Test Your Rule

Create spec/ameba-custom-rules/no_sleep_in_production_spec.cr:

require "../spec_helper"

module Ameba::Rule::Custom
  describe NoSleepInProduction do
    it "reports sleep calls" do
      rule = NoSleepInProduction.new

      source = Source.new %(
        def process
          sleep 5.seconds
        end
      )

      rule.test(source)
      source.issues.size.should eq(1)
    end

    it "allows code without sleep" do
      rule = NoSleepInProduction.new

      source = Source.new %(
        def process
          puts "Processing"
        end
      )

      rule.test(source)
      source.issues.should be_empty
    end
  end
end

Advanced Rule Examples

Enforcing Naming Conventions

module Ameba::Rule::Custom
  # Enforces that service classes end with "Service"
  class ServiceClassNaming < Base
    properties do
      description "Service classes must end with 'Service'"
      enabled true
    end

    MSG = "Service class name should end with 'Service'"

    def test(source)
      AST::NodeVisitor.new self, source
    end

    def test(source, node : Crystal::ClassDef)
      class_name = node.name.to_s

      # Check if class is in services directory
      return unless source.path.includes?("/services/")

      # Check if name ends with Service
      unless class_name.ends_with?("Service")
        issue_for node.name, MSG
      end
    end
  end
end

Detecting Dangerous Method Calls

module Ameba::Rule::Custom
  # Prevents dangerous ActiveRecord-like methods
  class NoDangerousDatabaseCalls < Base
    properties do
      description "Prevents dangerous database operations"
      dangerous_methods ["delete_all", "destroy_all", "update_all"]
    end

    MSG = "Dangerous method %s without conditions"

    def test(source)
      AST::NodeVisitor.new self, source
    end

    def test(source, node : Crystal::Call)
      return unless dangerous_methods.includes?(node.name)

      # Check if call has arguments (conditions)
      if node.args.empty?
        message = MSG % node.name
        issue_for node, message
      end
    end
  end
end

Enforcing Error Handling

module Ameba::Rule::Custom
  # Ensures HTTP client calls have error handling
  class HttpErrorHandling < Base
    properties do
      description "HTTP client calls must handle errors"
      enabled true
    end

    MSG = "HTTP client calls should be wrapped in begin/rescue"

    def test(source)
      @in_rescue_block = false
      AST::NodeVisitor.new self, source
    end

    def test(source, node : Crystal::ExceptionHandler)
      @in_rescue_block = true
      true  # Continue visiting children
    end

    def test(source, node : Crystal::Call)
      return if @in_rescue_block

      # Check for HTTP client calls
      if node.obj.try(&.to_s.includes?("HTTP"))
        issue_for node, MSG
      end
    end
  end
end

Validating Method Complexity

module Ameba::Rule::Custom
  # Limits method complexity
  class MethodComplexity < Base
    properties do
      description "Methods should not be too complex"
      max_complexity 10
    end

    MSG = "Method complexity (%d) exceeds maximum (%d)"

    def test(source)
      AST::NodeVisitor.new self, source
    end

    def test(source, node : Crystal::Def)
      complexity = calculate_complexity(node)

      if complexity > max_complexity
        message = MSG % [complexity, max_complexity]
        issue_for node, message
      end
    end

    private def calculate_complexity(node)
      counter = ComplexityCounter.new
      node.accept(counter)
      counter.complexity
    end

    private class ComplexityCounter < Crystal::Visitor
      getter complexity : Int32 = 1

      def visit(node : Crystal::If)
        @complexity += 1
        true
      end

      def visit(node : Crystal::Case)
        @complexity += 1
        true
      end

      def visit(node : Crystal::While)
        @complexity += 1
        true
      end

      def visit(node : Crystal::Call)
        # Count logical operators
        if node.name.in?("&&", "||")
          @complexity += 1
        end
        true
      end

      def visit(node : Crystal::ASTNode)
        true
      end
    end
  end
end

Enforcing Documentation Standards

module Ameba::Rule::Custom
  # Requires documentation with specific format
  class DocumentationFormat < Base
    properties do
      description "Public methods must have documentation with examples"
      enabled true
      require_examples true
    end

    MSG_NO_DOC = "Public method must have documentation"
    MSG_NO_EXAMPLE = "Documentation must include usage examples"

    def test(source)
      AST::NodeVisitor.new self, source
    end

    def test(source, node : Crystal::Def)
      return unless node.visibility.public?
      return if node.name.starts_with?("initialize")

      doc = node.doc

      if doc.nil? || doc.empty?
        issue_for node, MSG_NO_DOC
        return
      end

      if require_examples && !has_example?(doc)
        issue_for node, MSG_NO_EXAMPLE
      end
    end

    private def has_example?(doc : String)
      doc.includes?("```") || doc.includes?("Example:")
    end
  end
end

Working with AST Nodes

Common AST Node Types

# Class definitions
def test(source, node : Crystal::ClassDef)
  node.name            # Class name
  node.visibility      # public?, private?, protected?
  node.doc            # Documentation comment
  node.abstract?      # Is abstract class?
  node.superclass     # Parent class
end

# Method definitions
def test(source, node : Crystal::Def)
  node.name           # Method name
  node.args           # Arguments
  node.body           # Method body
  node.return_type    # Return type annotation
  node.visibility     # Visibility modifier
  node.doc           # Documentation
end

# Method calls
def test(source, node : Crystal::Call)
  node.name          # Method name
  node.obj           # Receiver object
  node.args          # Arguments
  node.named_args    # Named arguments
  node.block         # Block argument
end

# Variable assignments
def test(source, node : Crystal::Assign)
  node.target        # Left side (variable)
  node.value         # Right side (value)
end

# Conditionals
def test(source, node : Crystal::If)
  node.cond          # Condition
  node.then          # Then branch
  node.else          # Else branch
end

# Loops
def test(source, node : Crystal::While)
  node.cond          # Loop condition
  node.body          # Loop body
end

Traversal Patterns

# Pattern 1: Track state during traversal
class MyRule < Base
  def test(source)
    @inside_block = false
    @depth = 0
    AST::NodeVisitor.new self, source
  end

  def test(source, node : Crystal::Block)
    @inside_block = true
    @depth += 1
    true  # Continue visiting children
  end
end

# Pattern 2: Collect information then analyze
class MyRule < Base
  def test(source)
    @method_names = [] of String
    visitor = AST::NodeVisitor.new self, source
    analyze_collected_data(source)
  end

  def test(source, node : Crystal::Def)
    @method_names << node.name
  end

  private def analyze_collected_data(source)
    # Analyze @method_names
  end
end

# Pattern 3: Parent-child relationships
class MyRule < Base
  def test(source, node : Crystal::ClassDef)
    # Visit only methods in this class
    node.body.accept(MethodVisitor.new(self, source))
  end

  private class MethodVisitor < Crystal::Visitor
    def initialize(@rule : MyRule, @source : Source)
    end

    def visit(node : Crystal::Def)
      @rule.check_method(node, @source)
      true
    end

    def visit(node : Crystal::ASTNode)
      true
    end
  end
end

Rule Configuration

Configurable Properties

module Ameba::Rule::Custom
  class ConfigurableRule < Base
    properties do
      description "A rule with configurable properties"

      # Boolean properties
      enabled true
      strict_mode false

      # Numeric properties
      max_length 100
      min_length 3

      # String properties
      prefix "test_"
      suffix "_spec"

      # Array properties
      allowed_names ["foo", "bar", "baz"]
      excluded_paths ["spec/**/*", "lib/**/*"]
    end

    def test(source)
      # Use properties
      return unless enabled

      if strict_mode
        # Apply strict checks
      end

      AST::NodeVisitor.new self, source
    end
  end
end

Configuration in .ameba.yml

Custom/ConfigurableRule:
  Enabled: true
  Severity: Warning
  StrictMode: true
  MaxLength: 120
  MinLength: 5
  Prefix: "app_"
  AllowedNames:
    - "primary"
    - "secondary"
  ExcludedPaths:
    - "spec/fixtures/**"
    - "db/migrations/**"

Issue Reporting

Basic Issue Reporting

# Simple issue
issue_for node, "Error message"

# Issue with interpolation
issue_for node, "Found #{count} violations"

# Issue at specific location
issue_for node.name, "Method name violates convention"

# Issue with custom location
issue_for(
  {node.location.try(&.line_number) || 1, 1},
  node.end_location,
  "Custom message"
)

Issue Severity

# Controlled by configuration
Custom/MyRule:
  Severity: Error    # Blocks CI
  # or
  Severity: Warning  # Important but not blocking
  # or
  Severity: Convention  # Style preference

Rich Issue Messages

module Ameba::Rule::Custom
  class RichMessages < Base
    MSG_TEMPLATE = <<-MSG
      Method '%{method}' is too long (%{actual} lines, max %{max} allowed)
      Consider extracting to smaller methods or using composition.
      MSG

    def test(source, node : Crystal::Def)
      line_count = count_lines(node)

      if line_count > max_lines
        message = MSG_TEMPLATE % {
          method: node.name,
          actual: line_count,
          max: max_lines
        }
        issue_for node, message
      end
    end
  end
end

Testing Custom Rules

Comprehensive Test Suite

require "../spec_helper"

module Ameba::Rule::Custom
  describe DocumentedClasses do
    context "with documented class" do
      it "passes" do
        rule = DocumentedClasses.new

        source = Source.new %(
          # This is a documented class
          class MyClass
          end
        )

        rule.test(source)
        source.issues.should be_empty
      end
    end

    context "with undocumented public class" do
      it "reports an issue" do
        rule = DocumentedClasses.new

        source = Source.new %(
          class MyClass
          end
        )

        rule.test(source)
        source.issues.size.should eq(1)
        source.issues.first.message.should contain("documented")
      end
    end

    context "with private class" do
      it "allows undocumented private classes" do
        rule = DocumentedClasses.new

        source = Source.new %(
          private class InternalClass
          end
        )

        rule.test(source)
        source.issues.should be_empty
      end
    end

    context "with empty documentation" do
      it "reports an issue" do
        rule = DocumentedClasses.new

        source = Source.new %(
          #
          class MyClass
          end
        )

        rule.test(source)
        source.issues.size.should eq(1)
      end
    end

    context "configuration" do
      it "can be disabled" do
        rule = DocumentedClasses.new
        rule.enabled = false

        source = Source.new %(
          class MyClass
          end
        )

        rule.test(source)
        source.issues.should be_empty
      end
    end
  end
end

Test Helpers

# spec/spec_helper.cr
require "spec"
require "ameba"
require "../src/ameba-custom-rules"

module Ameba
  # Helper to create test sources
  def self.source(code : String, path = "source.cr")
    Source.new(code, path)
  end

  # Helper to expect issues
  def self.expect_issue(rule, code)
    source = Source.new(code)
    rule.test(source)
    source.issues.empty?.should be_false
  end

  # Helper to expect no issues
  def self.expect_no_issue(rule, code)
    source = Source.new(code)
    rule.test(source)
    source.issues.should be_empty
  end
end

# Usage in specs
describe MyRule do
  it "reports violations" do
    rule = MyRule.new
    Ameba.expect_issue rule, %(
      def bad_code
      end
    )
  end
end

Packaging and Distribution

Creating a Reusable Rule Package

# shard.yml
name: ameba-company-rules
version: 1.0.0

description: |
  Company-specific Ameba rules for Crystal projects.
  Enforces coding standards and best practices.

authors:
  - Company DevTools <devtools@company.com>

crystal: ">= 1.0.0"
license: MIT

development_dependencies:
  ameba:
    github: crystal-ameba/ameba
    version: ~> 1.6.0

# Optional: Add to targets for binary
targets:
  ameba-company:
    main: src/cli.cr

Distribution Strategy

# Option 1: As a shard dependency
# In user's shard.yml
development_dependencies:
  ameba:
    github: crystal-ameba/ameba
  ameba-company-rules:
    github: company/ameba-company-rules

# Option 2: As vendored rules
# Copy rule files to project's lib/ameba-rules/
# Include in custom ameba binary

# Option 3: As a plugin
# Create standalone executable that extends ameba

Custom Ameba Binary

# bin/ameba-custom.cr
require "ameba/cli"
require "../lib/ameba-company-rules/src/ameba-company-rules"

# Rules are automatically discovered
Ameba::CLI.run

Build and distribute:

crystal build bin/ameba-custom.cr -o bin/ameba-custom
# Distribute binary or build from source

Real-World Rule Examples

Preventing N+1 Queries

module Ameba::Rule::Custom
  class PreventNPlusOne < Base
    properties do
      description "Detects potential N+1 query patterns"
    end

    MSG = "Potential N+1 query: accessing association in loop"

    def test(source)
      @in_loop = false
      AST::NodeVisitor.new self, source
    end

    def test(source, node : Crystal::While | Crystal::Call)
      if node.is_a?(Crystal::Call) && node.name.in?("each", "map")
        @in_loop = true
        node.block.try(&.accept(self))
        @in_loop = false
        return false  # Don't visit block again
      end
      true
    end

    def test(source, node : Crystal::Call)
      return unless @in_loop

      # Detect association access patterns
      if looks_like_association?(node)
        issue_for node, MSG
      end
    end

    private def looks_like_association?(node)
      # Simplified detection
      node.name.in?("user", "posts", "comments") &&
        node.obj != nil
    end
  end
end

Enforcing API Versioning

module Ameba::Rule::Custom
  class ApiVersioning < Base
    properties do
      description "API controllers must be versioned"
    end

    MSG = "API controller must be in a versioned namespace (e.g., V1::)"

    def test(source)
      return unless source.path.includes?("/api/")
      AST::NodeVisitor.new self, source
    end

    def test(source, node : Crystal::ClassDef)
      return unless node.name.to_s.ends_with?("Controller")

      unless has_version_namespace?(node)
        issue_for node.name, MSG
      end
    end

    private def has_version_namespace?(node)
      # Check if class name includes version (V1::, V2::, etc.)
      node.name.to_s.matches?(/V\d+::/)
    end
  end
end

When to Use This Skill

Use the ameba-custom-rules skill when:

  • Enforcing project-specific coding standards not covered by built-in rules
  • Detecting domain-specific anti-patterns or code smells
  • Validating business logic constraints in code
  • Creating organization-wide linting standards
  • Migrating from another language and enforcing new patterns
  • Preventing specific bugs that have occurred in production
  • Ensuring consistency across microservices
  • Teaching team members about code quality through automated feedback
  • Enforcing architectural decisions (e.g., layer boundaries)
  • Standardizing error handling, logging, or monitoring patterns

Best Practices

  1. Start simple - Begin with basic rules before tackling complex AST traversals
  2. Test thoroughly - Write comprehensive specs covering edge cases
  3. Provide clear messages - Error messages should explain what's wrong and suggest fixes
  4. Make rules configurable - Use properties for thresholds and options
  5. Document your rules - Include description and examples in properties block
  6. Use specific node types - Overload test for specific AST nodes, not generic traversal
  7. Consider performance - Avoid complex operations in hot paths; cache results when possible
  8. Follow naming conventions - Use descriptive rule names that match their purpose
  9. Provide fix suggestions - When possible, explain how to resolve the issue
  10. Scope appropriately - Only check relevant files (use source.path checks)
  11. Handle nil safely - Use try(&.) when accessing potentially nil AST properties
  12. Avoid false positives - Better to miss some cases than flag correct code
  13. Version your rules - Track rule versions and breaking changes
  14. Keep rules focused - One rule should check one thing (Single Responsibility)
  15. Integrate with CI - Ensure custom rules work in automated environments

Common Pitfalls

  1. Overly broad matching - Catching too many cases and producing false positives
  2. Not handling nil - AST nodes may have nil properties causing crashes
  3. Ignoring visibility - Checking private methods when only public API matters
  4. Complex visitor logic - Making traversal code hard to understand and maintain
  5. Missing edge cases - Not testing unusual but valid code patterns
  6. Poor error messages - Vague messages that don't help developers fix issues
  7. Hard-coded values - Not making thresholds and options configurable
  8. Checking generated code - Flagging auto-generated files that shouldn't be changed
  9. Performance issues - Complex rules that slow down analysis significantly
  10. Dependency conflicts - Using Ameba as regular dependency instead of development_dependencies
  11. Not using properties - Hard-coding configuration instead of using properties block
  12. Incomplete testing - Not testing disabled state, edge cases, or configuration
  13. Tight coupling - Rules that depend on other rules or specific file structures
  14. Unclear scope - Rules that apply to wrong files or contexts
  15. Version incompatibility - Not testing against multiple Ameba versions

Resources