Metastatic.Analysis.Analyzer behaviour (Metastatic v0.10.4)

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

Analysis category classification

Analysis context passed to callbacks

Analyzer metadata

Analysis issue result

Issue location information

Issue severity level

Refactoring suggestion

Callbacks

Analyzes a single AST node in context.

Returns metadata about this analyzer.

Optional: Called once after AST traversal completes.

Optional: Called once before AST traversal starts.

Functions

Extracts the category from an analyzer module.

Checks if an analyzer is configurable.

Creates an issue map with required fields.

Extracts the name from an analyzer module.

Creates a suggestion map.

Validates that a module implements the Analyzer behaviour correctly.

Types

category()

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

Analysis category classification

context()

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

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