Search documentation of Metastatic

# Metastatic.Analysis.Analyzer behaviour (Metastatic v0.13.2)

View Source

Behaviour for MetaAST analyzers and refactoring suggestions.

Analyzers can be diagnostic (detecting issues) or prescriptive (suggesting improvements). Both types work uniformly through this behaviour.

## Philosophy

Analyzers operate at the M2 meta-model level, making them language-agnostic. Write an analyzer once, and it works across Python, JavaScript, Elixir, and all other supported languages.

## Usage

    defmodule MyApp.Analysis.UnusedVariables do
      @behaviour Metastatic.Analysis.Analyzer

      @impl true
      def info do
        %{
          name: :unused_variables,
          category: :correctness,
          description: "Detects variables that are assigned but never used",
          severity: :warning,
          explanation: "Unused variables add noise and may indicate bugs",
          configurable: true
        }
      end

      @impl true
      # New 3-tuple format: {:assignment, meta, [target, value]}
      def analyze({:assignment, _meta, [{:variable, _, name}, _value]}, context) do
        # Track assignment and return issues
        []
      end

      def analyze(_node, _context), do: []
    end

## Lifecycle

1. `run_before/1` (optional) - Called once before traversal
2. `analyze/2` - Called for each AST node during traversal
3. `run_after/2` (optional) - Called once after traversal

## Context

The context map passed to callbacks contains:

- `:document` - The document being analyzed
- `:config` - Configuration for this analyzer
- `:parent_stack` - Stack of parent nodes
- `:depth` - Current depth in AST
- `:scope` - Scope tracking (custom per analyzer)

Analyzers can store state in the context by returning modified context from `run_before/1` or by maintaining their own state tracking.

## Summary

### Types

category()

Analysis category classification

context()

Analysis context passed to callbacks

info()

Analyzer metadata

issue()

Analysis issue result

location()

Issue location information

severity()

Issue severity level

suggestion()

Refactoring suggestion

### Callbacks

analyze(node, context)

Analyzes a single AST node in context.

info()

Returns metadata about this analyzer.

run_after(context, issues)

Optional: Called once after AST traversal completes.

run_before(context)

Optional: Called once before AST traversal starts.

### Functions

category(module)

Extracts the category from an analyzer module.

configurable?(module)

Checks if an analyzer is configurable.

issue(opts)

Creates an issue map with required fields.

name(module)

Extracts the name from an analyzer module.

suggestion(opts)

Creates a suggestion map.

valid?(module)

Validates that a module implements the Analyzer behaviour correctly.

## Types

# category()

    @type category() ::
      :readability
      | :maintainability
      | :performance
      | :security
      | :correctness
      | :style
      | :refactoring
      | :reliability
      | :observability

Analysis category classification

# context()

    @type context() :: %{
      :document => Metastatic.Document.t(),
      :config => map(),
      :parent_stack => [Metastatic.AST.meta_ast()],
      :depth => non_neg_integer(),
      :scope => map(),
      optional(atom()) => term()
    }

Analysis context passed to callbacks

# info()

    @type info() :: %{
      name: atom(),
      category: category(),
      description: String.t(),
      severity: severity(),
      explanation: String.t(),
      configurable: boolean()
    }

Analyzer metadata

# issue()

    @type issue() :: %{
      analyzer: module(),
      category: category(),
      severity: severity(),
      message: String.t(),
      node: Metastatic.AST.meta_ast(),
      location: location(),
      suggestion: suggestion() | nil,
      metadata: map()
    }

Analysis issue result

# location()

    @type location() :: %{
      line: non_neg_integer() | nil,
      column: non_neg_integer() | nil,
      path: Path.t() | nil
    }

Issue location information

# severity()

    @type severity() :: :error | :warning | :info | :refactoring_opportunity

Issue severity level

# suggestion()

    @type suggestion() :: %{
      type: :replace | :remove | :insert_before | :insert_after,
      replacement: Metastatic.AST.meta_ast() | nil,
      message: String.t()
    }

Refactoring suggestion

## Callbacks

# analyze(node, context)

    @callback analyze(node :: Metastatic.AST.meta_ast(), context :: context()) :: [issue()]

Analyzes a single AST node in context.

Called once per node during AST traversal. Returns a list of issues found at this node. Return empty list if no issues.

The context includes:

- Current document
- Analyzer configuration
- Parent node stack
- Current depth
- Custom scope data

## Examples

    @impl true
    # New 3-tuple format: {:literal, [subtype: :integer, ...], value}
    def analyze({:literal, meta, value} = node, _context) do
      subtype = Keyword.get(meta, :subtype)
      if subtype == :integer and value > 1000 do
        [
          %{
            analyzer: __MODULE__,
            category: :style,
            severity: :warning,
            message: "Large literal value #{value}",
            node: node,
            location: %{line: nil, column: nil, path: nil},
            suggestion: nil,
            metadata: %{value: value}
          }
        ]
      else
        []
      end
    end

    def analyze(_node, _context), do: []

# info()

    @callback info() :: info()

Returns metadata about this analyzer.

Must include:

- `:name` - Unique identifier (atom)
- `:category` - Analysis category
- `:description` - Brief one-line description
- `:severity` - Default severity level
- `:explanation` - Detailed explanation (can be multi-line)
- `:configurable` - Whether analyzer accepts configuration

## Examples

    @impl true
    def info do
      %{
        name: :unused_variables,
        category: :correctness,
        description: "Detects variables that are assigned but never used",
        severity: :warning,
        explanation: """
        Variables that are assigned but never referenced add noise and
        may indicate bugs or incomplete code.
        """,
        configurable: true
      }
    end

# run_after(context, issues)

(optional)

    @callback run_after(context :: context(), issues :: [issue()]) :: [issue()]

Optional: Called once after AST traversal completes.

Use this for:

- Final analysis requiring full AST knowledge
- Cross-node validation
- Generating summary issues

Receives all issues collected so far. Can return modified or additional issues.

## Examples

    @impl true
    def run_after(context, issues) do
      # Generate issues from collected state
      assigned = Map.keys(context.assigned)
      used = context.used

      unused = Enum.filter(assigned, fn var ->
        not MapSet.member?(used, var)
      end)

      new_issues = Enum.map(unused, fn var ->
        %{
          analyzer: __MODULE__,
          category: :correctness,
          severity: :warning,
          message: "Variable '#{var}' is assigned but never used",
          node: {:variable, [], var},  # New 3-tuple format
          location: %{line: nil, column: nil, path: nil},
          suggestion: nil,
          metadata: %{variable: var}
        }
      end)

      issues ++ new_issues
    end

# run_before(context)

(optional)

    @callback run_before(context :: context()) :: {:ok, context()} | {:skip, reason :: term()}

Optional: Called once before AST traversal starts.

Use this for:

- Initializing analyzer state
- Pre-processing the document
- Checking if analysis should proceed

Return `{:ok, context}` to continue with (possibly modified) context. Return `{:skip, reason}` to skip this analyzer entirely.

## Examples

    @impl true
    def run_before(context) do
      # Initialize state in context
      context = Map.put(context, :assigned, %{})
      context = Map.put(context, :used, MapSet.new())
      {:ok, context}
    end

    # Or skip if conditions not met
    def run_before(context) do
      if context.document.language == :unsupported do
        {:skip, :unsupported_language}
      else
        {:ok, context}
      end
    end

## Functions

# category(module)

    @spec category(module()) :: category()

Extracts the category from an analyzer module.

## Examples

    iex> Analyzer.category(module)
    :correctness

# configurable?(module)

    @spec configurable?(module()) :: boolean()

Checks if an analyzer is configurable.

## Examples

    iex> Analyzer.configurable?(module)
    true

# issue(opts)

    @spec issue(keyword()) :: issue()

Creates an issue map with required fields.

Automatically extracts location information from the node's metadata if available. If the node has M1 context metadata (module, function, arity, etc.), it will be included in the location.

## Examples

    # New 3-tuple format example
    iex> Analyzer.issue(
    ...>   analyzer: MyAnalyzer,
    ...>   category: :style,
    ...>   severity: :warning,
    ...>   message: "Found an issue",
    ...>   node: {:literal, [subtype: :integer], 42}
    ...> )
    %{
      analyzer: MyAnalyzer,
      category: :style,
      severity: :warning,
      message: "Found an issue",
      node: {:literal, [subtype: :integer], 42},
      location: %{line: nil, column: nil, path: nil},
      suggestion: nil,
      metadata: %{}
    }

    # With M1 context metadata in node (new 3-tuple format):
    iex> node_with_context = {:variable, [line: 10, module: "MyApp", function: "foo", arity: 2], "x"}
    iex> Analyzer.issue(
    ...>   analyzer: MyAnalyzer,
    ...>   category: :style,
    ...>   severity: :warning,
    ...>   message: "Found an issue",
    ...>   node: node_with_context
    ...> )
    %{
      analyzer: MyAnalyzer,
      category: :style,
      severity: :warning,
      message: "Found an issue",
      node: node_with_context,
      location: %{line: 10, column: nil, path: nil, module: "MyApp", function: "foo", arity: 2},
      suggestion: nil,
      metadata: %{}
    }

# name(module)

    @spec name(module()) :: atom()

Extracts the name from an analyzer module.

## Examples

    iex> module.info()
    %{name: :unused_variables, ...}
    iex> Analyzer.name(module)
    :unused_variables

# suggestion(opts)

    @spec suggestion(keyword()) :: suggestion()

Creates a suggestion map.

## Examples

    iex> Analyzer.suggestion(
    ...>   type: :replace,
    ...>   replacement: {:variable, [], "CONSTANT"},
    ...>   message: "Extract to constant"
    ...> )
    %{
      type: :replace,
      replacement: {:variable, [], "CONSTANT"},
      message: "Extract to constant"
    }

# valid?(module)

    @spec valid?(module()) :: boolean()

Validates that a module implements the Analyzer behaviour correctly.

Checks:

- Required functions are exported
- info/0 returns valid structure
- Required info keys are present

## Examples

    iex> Analyzer.valid?(MyAnalyzer)
    true

    iex> Analyzer.valid?(NotAnAnalyzer)
    false