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: []
endLifecycle
run_before/1(optional) - Called once before traversalanalyze/2- Called for each AST node during traversalrun_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
@type category() ::
:readability
| :maintainability
| :performance
| :security
| :correctness
| :style
| :refactoring
Analysis category classification
@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
@type info() :: %{ name: atom(), category: category(), description: String.t(), severity: severity(), explanation: String.t(), configurable: boolean() }
Analyzer metadata
@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
@type location() :: %{ line: non_neg_integer() | nil, column: non_neg_integer() | nil, path: Path.t() | nil }
Issue location information
@type severity() :: :error | :warning | :info | :refactoring_opportunity
Issue severity level
@type suggestion() :: %{ type: :replace | :remove | :insert_before | :insert_after, replacement: Metastatic.AST.meta_ast() | nil, message: String.t() }
Refactoring suggestion
Callbacks
@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: []
@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
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
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
Extracts the category from an analyzer module.
Examples
iex> Analyzer.category(module)
:correctness
Checks if an analyzer is configurable.
Examples
iex> Analyzer.configurable?(module)
true
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: %{}
}
Extracts the name from an analyzer module.
Examples
iex> module.info()
%{name: :unused_variables, ...}
iex> Analyzer.name(module)
:unused_variables
@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"
}
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