Muex.Mutator behaviour (Muex v0.7.0)

View Source

Behaviour for mutation operators that transform AST nodes.

Mutators implement specific mutation strategies (e.g., arithmetic operators, boolean operators, literals) and return a list of possible mutations for a given AST.

Each mutator declares which languages it supports via supported_languages/0. Mutators targeting the same AST family (e.g., Elixir and Erlang both use BEAM AST) can declare support for multiple languages.

Traversal and Pruning

walk/3 performs a context-aware, pre-order traversal of the AST. It threads the nearest enclosing source line through context[:line] and prunes subtrees that should never be mutated (module aliases, use/import/alias/require, documentation and typespec attributes, and keyword/option keys). Callers can prune additional framework DSL calls via context[:skip_calls]. See walk/3 for the full set of rules.

Equivalent Mutations

Mutators can declare that a generated mutation is semantically equivalent to the original code — meaning no test can ever kill it. This avoids polluting mutation scores with false negatives.

There are two ways to mark equivalence:

  1. At generation time — set equivalent: true in the mutation map returned by mutate/2. Use this when the mutator knows at generation time that the mutation is equivalent (e.g., swapping arguments to a commutative operator).

  2. Via the equivalent?/1 callback — implement this for more complex analysis that needs to inspect the full mutation map. The default implementation checks the :equivalent key.

Example

defmodule Muex.Mutator.MyMutator do
  @behaviour Muex.Mutator

  @impl true
  def mutate(ast, _context) do
    # Return list of mutated AST variants
    [mutated_ast_1, mutated_ast_2]
  end

  @impl true
  def name, do: "My Mutator"

  @impl true
  def description, do: "Mutates specific AST patterns"

  @impl true
  def supported_languages, do: [Muex.Language.Elixir, Muex.Language.Erlang]

  # Optional: override for complex equivalence detection
  @impl true
  def equivalent?(%{description: "swap arguments in +()" <> _}), do: true
  def equivalent?(_mutation), do: false
end

Summary

Types

Represents a single mutation with its metadata.

Callbacks

Returns a description of what this mutator does.

Returns whether a mutation is semantically equivalent to the original code.

Applies mutations to the given AST.

Returns the name of the mutator.

Returns the list of language adapter modules this mutator supports.

Functions

Checks whether a mutation is equivalent, delegating to the mutator module.

Walks an AST and collects mutations from all registered mutators.

Types

mutation()

@type mutation() :: %{
  ast: term(),
  original_ast: term(),
  mutator: module(),
  description: String.t(),
  location: %{file: String.t(), line: non_neg_integer()}
}

Represents a single mutation with its metadata.

The :equivalent key is optional. When true, the mutation is considered semantically equivalent to the original and will be filtered out by the optimizer.

Callbacks

description()

@callback description() :: String.t()

Returns a description of what this mutator does.

equivalent?(mutation)

(optional)
@callback equivalent?(mutation :: mutation()) :: boolean()

Returns whether a mutation is semantically equivalent to the original code.

Equivalent mutations can never be killed by any test and should be filtered out to avoid inflating the "survived" count.

The default implementation checks for equivalent: true in the mutation map. Override this callback in your mutator for more sophisticated detection.

mutate(ast, context)

@callback mutate(ast :: term(), context :: map()) :: [mutation()]

Applies mutations to the given AST.

Parameters

  • ast - The AST to mutate
  • context - Map containing additional context (file path, line number, etc.)

Returns

List of mutation maps, each representing a possible mutation

name()

@callback name() :: String.t()

Returns the name of the mutator.

supported_languages()

@callback supported_languages() :: [module()]

Returns the list of language adapter modules this mutator supports.

Mutators that work with the same AST format (e.g., BEAM languages like Elixir and Erlang) can declare multiple languages. Discovery will filter mutators based on the active language.

Returns

List of language adapter modules (e.g., [Muex.Language.Elixir, Muex.Language.Erlang])

Functions

equivalent?(mutation)

@spec equivalent?(mutation()) :: boolean()

Checks whether a mutation is equivalent, delegating to the mutator module.

Falls back to checking the :equivalent key in the mutation map if the mutator does not implement equivalent?/1.

walk(ast, mutators, context)

@spec walk(ast :: term(), mutators :: [module()], context :: map()) :: [mutation()]

Walks an AST and collects mutations from all registered mutators.

The traversal is context-aware: it threads the nearest enclosing source line through context[:line] (so leaf mutators such as Muex.Mutator.Literal can report a real location for bare literals that carry no AST metadata of their own) and prunes subtrees that should never be mutated.

Pruning rules

The following are skipped for every mutator, regardless of configuration:

  • Module alias segments ({:__aliases__, _, _}), e.g. the Phoenix and Component atoms in use Phoenix.Component.
  • Directives: use, import, alias, require.
  • Documentation and typespec attributes: @doc, @moduledoc, @typedoc, @type, @typep, @opaque, @spec, @callback, @macrocallback, @behaviour, @impl, @derive, @enforce_keys.
  • Keyword/option keys: in a key: value pair only value is traversed, never the atom key.

Callers can prune additional framework DSL calls by passing a list of call names (atoms) in context[:skip_calls]. For example, [:attr, :slot, :scope, :pipeline] removes Phoenix component and router DSL noise. See Muex.Config presets.

Parameters

  • ast - The AST to traverse
  • mutators - List of mutator modules to apply
  • context - Context map. Recognised keys:
    • :file - source file path, copied into each mutation's location
    • :line - starting line (defaults to 0); updated as the walk descends
    • :skip_calls - extra call names (atoms) to prune

Returns

List of all mutations found in the AST. Each mutation is augmented with :original_ast (the matched node), used later during application.