Mutare.Mutator behaviour (mutare v0.1.0)

Copy Markdown View Source

Behaviour for mutators: modules that produce AST replacements.

A mutator examines an AST node and returns either :skip or a list of mutations to generate at that site. Every mutator defines name/0 and at least one mutation-producing callback.

You must define name/0 to identify the mutator in reports, and at least one mutation-producing callback. The usual producer is mutate/1 (or the pipe-aware/configurable mutate/2), but a Mutare.Mutator.Structural hook or a Mutare.Mutator.MacroHost.host/2 selector host counts too — a mutator that produces only through one of those needs no mutate/1. Optionally, you may implement variants/0 and variant/2 to classify your mutations into kinds, mutate_call_option_keys?/1 to control mutations of call-option names, and argument_marks/1 to have the transform mark specific call-argument positions (e.g. timeout literals) that you then recognise with marked?/2 in mutate/2 and decline.

When both mutate/1 and mutate/2 are exported, Mutare calls mutate/2. If a mutator needs both context-free and context-aware production, call the context-free helper explicitly from mutate/2.

Structural positions use Mutare.Mutator.Structural. Macro-aware mutators use Mutare.CallRouting, and mutators that emit mutations inside hosted DSL fragments also use Mutare.Mutator.MacroHost.

Writing a mutator

Match the node shapes to mutate and rebuild them with the changed node. Reuse the original operand AST when possible so the mutation stays small:

defmodule MyApp.Mutators.AndOr do
  @behaviour Mutare.Mutator

  @impl true
  def name, do: :and_or

  @impl true
  def mutate({:and, meta, [left, right]}), do: [{:or, meta, [left, right]}]
  def mutate({:or, meta, [left, right]}), do: [{:and, meta, [left, right]}]
  def mutate(_node), do: :skip
end

Two rules are important:

  • Keep every replacement compile-safe. Mutare compiles one shared metamutant containing all emitted mutants.
  • Do not choose delivery placement. The transform decides whether a mutation is delivered in place or through lifting based on where the node appears.

Build literal replacements with Mutare.AST.literal/1. Use Mutare.Calls.resolved_call_to/3 when matching aliased or imported calls.

Registering a mutator

List it under :mutators in .mutare.exs alongside, or instead of, built-in family atoms:

[mutators: [:arithmetic, :relational, MyApp.Mutators.AndOr]]

Configuring a mutator ({module, opts})

Register a configurable mutator as {module, opts}. The options are available as context.opts. Node-level configurable mutators implement mutate/2:

defmodule MyApp.Mutators.MagicNumber do
  @behaviour Mutare.Mutator
  def name, do: :magic_number

  def mutate({:__block__, _m, [n]}, %{opts: opts}) when is_integer(n) do
    case Keyword.get(opts, :swaps, %{})[n] do
      nil -> :skip
      to -> [{:__block__, [], [to]}]
    end
  end

  def mutate(_node, _context), do: :skip
end

# .mutare.exs
[mutators: [:arithmetic, {MyApp.Mutators.MagicNumber, swaps: %{200 => 500}}]]

The reserved :as key changes the recorded family name, allowing the same module to run more than once under distinct names. It is removed before options reach the mutator. See Mutare.Mutator.Spec.

A mutator with a rich option surface implements init/1 to parse and validate its options once, when the instance is resolved — before any file is read — instead of re-reading context.opts at every offered node. The value init/1 returns reaches every context-aware callback as context.config; a typo'd option raises at startup, next to Mutare's own option validation. Without init/1, context.config is the raw options. For the common "which of my families are enabled" option, see Mutare.Mutator.Families.

A mutator that must post-process everything it produces — typically to apply that family selection and attach per-family report notes — implements finalize/2, which Mutare applies to every produced mutation on every delivery path (a mutate/1/mutate/2 return and a hosted target's :mutants) just before recording, so the funnel cannot miss a delivery site.

Structural mutators use the context-aware structural arity instead: Mutare.Mutator.Structural.return_replacements/2, Mutare.Mutator.Structural.condition_replacements/2, or Mutare.Mutator.Structural.pattern_mutations/3.

A mutator that changes atom-like keys may implement mutate_call_option_keys?/1 to decide whether to mutate a trailing call-option key such as timeout: in foo(x, timeout: 5). This policy belongs to the mutator because a context-free atom replacement may turn an option name into an unknown key, while a call-aware family may replace one legal option key with another.

[mutators: [..., {Mutare.Mutators.AtomLiteral, call_option_keys: false}]]

Leaving argument positions alone

Two user-facing facilities cover "don't mutate here", and a mutator author meets both from the other side:

  • Routing (call_routes: in .mutare.exs, or Mutare.CallRouting.call_routes/0) is transform-enforced and positional: :skip makes a whole call an inert leaf, :raw leaves an argument as written, :interior mutates an argument's contents but not its own node, and a keyed refinement ([:expression, timeout: :raw]) reaches one option value of a literal keyword argument. No mutator is consulted — see Mutare.CallRouting.

  • Marks (argument_marks: in .mutare.exs, or argument_marks/1) are labels the transform stamps on positions and hands to mutators as context.marks; each mutator decides what a label means. This is how the built-in timeout table works — :timeout positions make Mutare.Mutators.IntegerLiteral decline any integer and Mutare.Mutators.AtomLiteral decline only :infinity, while every other family proceeds — and a user extends the same table for their own functions with the same declaration shape:

    [argument_marks: [
      {MyApp.Cache, :put, 3, [2], :timeout},                       # a positional argument
      {MyApp.Http, :get, 2, [{:keyword, :recv_timeout}], :timeout} # a trailing-option value
    ]]

A mark's meaning lives entirely in the mutators that read it (marked?/2), so a configured label must be one some enabled mutator declares positions for (declared_labels/1) — a typo fails at startup. Reach for a route when the position should simply not mutate; reach for a mark when the reaction should depend on the value (only a duration-shaped literal held back, a computed duration's sub-expressions still mutating).

Targeting a macro or DSL

A mutator whose mutation depends on a macro's arguments being routed specially implements Mutare.CallRouting and registers the macros from Mutare.CallRouting.call_routes/0. Routes may be static or use :routing with Mutare.CallRouting.route_arguments/2 for shape-aware classification. Listing the mutator in :mutators auto-registers them.

Core still offers the whole registered call to mutate/2, with context.mutators carrying the run's enabled specs — so a mutator that keeps a DSL argument raw (:raw) can rewrite the call itself and sub-contract the ordinary-Elixir islands inside that raw argument back to core's generation via Mutare.Analyze.expression_mutations/3, relaying each rebuild as a Mutare.Mutator.Mutation with producer: set (see Mutare.Analyze — the island is analyzed with the full set, so another mutator's registered macro inside it is offered to its owner the same way).

A mutator that produces mutations inside a compile-time DSL additionally implements Mutare.Mutator.MacroHost, subscribes with Mutare.Mutator.MacroHost.hosted_macros/0, and delivers foreign-DSL mutations through Mutare.Mutator.MacroHost.host/2. It need not own the DSL's routing: a separate extension may declare the :hosted position, and several hosts may subscribe to it. See the "which behaviours do I implement?" table in Mutare.CallRouting.

Structural mutators at routed positions (Mutare.Mutator.Structural)

Some targets are positions rather than individual nodes: a def/defp return tail, an if/unless/cond condition, or a structural pattern position. Pattern positions include def/defp heads, clause patterns, destructuring match patterns, and routed :binding_pattern macro arguments. Those callbacks live on Mutare.Mutator.Structural. A structural mutator declares both behaviours and implements the relevant structural callback.

The transform identifies the position, asks each enabled mutator that exports the matching callback, and records each emitted mutation under that mutator's name. Built-in examples are Mutare.Mutators.ReturnValue, Mutare.Mutators.IfCondition, and Mutare.Mutators.PatternSwap.

Behaviour-targeted mutators (context.behaviours)

A mutator can depend on behaviours implemented by the enclosing module. The behaviour set is available as context.behaviours, a MapSet of module atoms gathered from direct @behaviour attributes and use-injected behaviours.

defmodule MyApp.Mutators.GenServerReply do
  @behaviour Mutare.Mutator
  def name, do: :genserver_reply

  def mutate({:{}, m, [{:__block__, am, [:reply]}, _r, state]}, %{behaviours: bs}) do
    if MapSet.member?(bs, GenServer),
      do: [{:{}, m, [{:__block__, am, [:noreply]}, state]}],
      else: :skip
  end

  def mutate(_node, _context), do: :skip
end

Structural callbacks have context-aware arities carrying the same behaviour set: Mutare.Mutator.Structural.return_replacements/2, Mutare.Mutator.Structural.condition_replacements/2, and Mutare.Mutator.Structural.pattern_mutations/3. These callbacks receive context.behaviours and context.opts, so export the context-aware arity when a structural mutation depends on behaviours or configuration.

Matching aliased or imported calls (Mutare.Calls)

A mutator that targets a standard-library or remote call uses Mutare.Calls.resolved_call_to/3 with the real module atom (and optionally the function names it owns); it returns {:ok, function, arguments, rebuild} for resolved qualified, aliased, imported, and Erlang-atom module calls, and rebuild emits the replacement in the same written form as the source. For table-driven matching across modules, Mutare.Calls.resolved_call/1 returns the raw resolved tuple, keyed by Mutare.Calls.module_key/1.

Summary

Types

Context passed to mutate/2 at each runtime call site.

One argument-mark declaration: {module, function, arity, positions, label} — the shape argument_marks/1 returns and the argument_marks: option accepts. positions lists effective argument indices and {:keyword, key} trailing-option keys.

One element of a mutate/1 or mutate/2 return list.

A call node's pipe context, as an atom: :piped (the node is a |> right-hand side, so its effective first argument is the pipe's left side) or :unpiped. It is what the mutate/2 context's :pipe_mode carries, and the form effective_arity/2 and visible_index/2 take.

Callbacks

Asks the transform to mark certain argument positions of certain calls, so this mutator can recognise them at mutate/2 and decline to mutate there (or mutate differently).

Post-processes each produced mutation before it is recorded.

Parses the instance's options into its normalized configuration, once per resolved instance.

Produces node-level mutations for node.

Produces context-aware mutations for node.

Controls mutation of trailing keyword-option keys.

Short family name, shown in reports (e.g. :arithmetic).

Declares modules that must be loadable for this mutator's routing and hosting to be valid.

Derives variant labels from an emitted {original, mutated} pair.

Declares the variant labels this mutator supports in # mutare:ignore[family:label] filters.

Functions

Turns a list of {module, function, arity, positions} entries into argument_marks/1 declarations under label. positions is a list of effective argument indices and {:keyword, key} option keys, exactly as in a declaration; an index is validated against the declared arity. Raises ArgumentError with a pointed message on a malformed entry, so a typo fails at startup rather than silently marking nothing. The one-liner for a mutator that exposes its own "also leave these positions alone" option under its label.

The set of mark labels the given mutators declare positions for (argument_marks/1) — the labels that mean something to some enabled family. Mutare.Options checks a configured argument_marks: entry's label against this set, so a typo'd or orphaned label fails at startup instead of marking positions nobody reads.

Returns the effective arity of a call under its pipe context.

Whether the node being offered carries the position mark label — i.e. sits at a position some mutator requested via argument_marks/1. The reader half of the marking facility: a mutate/2 checks this and returns :skip (or adapts) at a marked position.

Classifies a binary operator swap for variant/2.

Validate a user-facing list of argument-mark declarations — the argument_marks: option in .mutare.exs, whose entries are exactly what argument_marks/1 returns: {module, function, arity, positions, label}. Shape-checked here (a bad index or a non-atom label fails at startup); whether label is one some configured mutator declares positions for is checked once the mutator set is known (Mutare.Options.new/1).

Converts an effective argument index to the index in the call node's visible argument list.

Types

context()

@type context() :: %{
  :pipe_mode => pipe_mode(),
  optional(:name) => atom(),
  optional(:opts) => term(),
  optional(:config) => term(),
  optional(:behaviours) => MapSet.t(module()),
  optional(:mutators) => [Mutare.Mutator.Spec.t()],
  optional(:marks) => MapSet.t(atom())
}

Context passed to mutate/2 at each runtime call site.

  • :pipe_mode:piped or :unpiped; when piped, the effective first argument is the pipe's left side and is not present in the node's own args.
  • :opts — the configured mutator's per-instance options (the opts of a {module, opts} entry in :mutators, with any :as name override stripped), or [] for an unconfigured mutator.
  • :config — the mutator's normalized configuration: what its init/1 returned for those options, or the raw options themselves when the mutator does not export init/1.
  • :behaviours — the enclosing module's behaviour set: a MapSet of the modules it implements via @behaviour Foo (directly or injected by a use). Empty outside a module.
  • :mutators — present for selector hosts (Mutare.Mutator.MacroHost.host/2) and for the whole-call mutate/2 offer of a registered macro call (a call some enabled mutator or extension registered via Mutare.CallRouting): the run's enabled Mutare.Mutator.Specs (hosts included), for sub-contracting ordinary-Elixir islands the macro's routing left raw back to core's generation via Mutare.Analyze.expression_mutations/3 — which lowers a nested host's targets to whole-call rebuilds instead of weaving them, so hosted delivery never nests while every surface (ordinary and hosted) participates. Absent on ordinary node offers — core fully descends an unregistered node itself, so sub-contracting there would produce the same mutant twice.

The keys other than :pipe_mode are optional in the type because the base context carries only :pipe_mode; dispatch injects the configured options, the normalized configuration, and the behaviour set (and, at the sub-contract seams above, the enabled specs) before calling a mutator.

mark_declaration()

@type mark_declaration() ::
  {module(), atom(), arity(), [non_neg_integer() | {:keyword, atom()}], atom()}

One argument-mark declaration: {module, function, arity, positions, label} — the shape argument_marks/1 returns and the argument_marks: option accepts. positions lists effective argument indices and {:keyword, key} trailing-option keys.

mutation()

@type mutation() :: Macro.t() | Mutare.Mutator.Mutation.t()

One element of a mutate/1 or mutate/2 return list.

  • A bare AST node is an ordinary replacement. A top-level bare nil item is rejected because it is too easy to confuse with “no replacement”; filter inapplicable entries before returning the list. To replace a node with the literal nil, return Mutare.AST.literal(nil).
  • A Mutare.Mutator.Mutation.t/0 carries a replacement plus metadata such as a report note, an ignore variant, or an :attribution — a report-location override (Mutare.Mutator.Mutation.at/2 / at_drop/1) for a whole-node rewrite, so a mutate/2 that rebuilds and returns an entire registered-macro call is reported at the specific inner clause it changed rather than at the call's line (see Mutare.Mutator.Mutation).

A plain map is not treated as mutation metadata because a quoted map is also a valid AST replacement. Selector hosts use the same forms for :mutants.

pipe_mode()

@type pipe_mode() :: :piped | :unpiped

A call node's pipe context, as an atom: :piped (the node is a |> right-hand side, so its effective first argument is the pipe's left side) or :unpiped. It is what the mutate/2 context's :pipe_mode carries, and the form effective_arity/2 and visible_index/2 take.

Callbacks

argument_marks(config)

(optional)
@callback argument_marks(config :: term()) :: [mark_declaration()]

Asks the transform to mark certain argument positions of certain calls, so this mutator can recognise them at mutate/2 and decline to mutate there (or mutate differently).

This is the general facility behind Mutare's "don't perturb an opaque literal" behaviour: a mutator, not the transform, owns the knowledge of which positions are special. The transform stays domain-agnostic — it stamps label on the resolved position and surfaces it back as context.marks (a MapSet of atoms); Mutare.Mutator.marked?/2 reads it. For example, Mutare.Mutators.IntegerLiteral marks the millisecond/:infinity timeout arguments of Process.sleep, GenServer.call, Task.await, Task.async_stream's :timeout option, … and skips them, so a near-unkillable off-by-one on a duration is never minted.

Called once per resolved instance (like init/1) with that instance's config — so a mutator can extend its built-in marks with positions from its own options. Return a list of declarations, each naming a resolved call and the positions to mark with a label:

@impl true
def argument_marks(_config), do: timeout_marks()

A declaration is {module, function, arity, positions, label}. A position is an effective argument index (a piped receiver counts as index 0) or a {:keyword, key} for a trailing-options key. Arity is effective too, so an option-bearing arity (Task.async_stream/3, /5) can be marked while a same-named arity whose trailing argument is ordinary data (/4, the MFA callback-args list) is left alone. Marks are resolved through the same alias/import machinery as call matching, so aliased and imported forms are covered and a shadowing alias is not. Only the named value node is marked, never an enclosing container, so unrelated mutations there (e.g. List collapsing an options list) are untouched.

Two mutators marking the same position union their labels; the label is a shared vocabulary, so a family can react to a label another declared (declare it too if that must survive the declarer being disabled). Users extend the same tables from configuration — an argument_marks: entry has exactly this shape, and its label must be one some enabled mutator declares (declared_labels/1). A mutator without this callback asks for no marks. argument_marks_from/2 turns a {module, function, arity, positions} list into declarations under a label.

finalize(mutation, context)

(optional)
@callback finalize(mutation(), context()) :: mutation() | :skip

Post-processes each produced mutation before it is recorded.

Mutare applies this hook to every mutation the mutator produces, on both delivery paths — each element of a mutate/1/mutate/2 return list and each element of a hosted target's :mutants (Mutare.Mutator.MacroHost.host/2) — with the same context the producing callback received (including context.config, see init/1). Return:

  • a mutation/0 — the (possibly rewrapped) mutation to record;
  • :skip — drop this mutation.

Because Mutare guarantees the hook runs at every delivery site, a family-rich mutator keeps its producers pure — return Mutare.Mutator.Mutation.tagged(node, [family | finer]) everywhere — and defines the tag → filter → enrich funnel once: finalize/2 reads the leading variant label as the family, drops mutations of disabled families (see Mutare.Mutator.Families), and attaches the family's report note. Delivery code shrinks to pure production, and forgetting a site cannot silently deliver unfiltered, note-less mutants.

Finalization is part of production, not reporting: it runs before overlap suppression and # mutare:ignore filtering, and variant labels carried by the finalized mutation win over variant/2 derivation exactly as at production. Two kinds of mutation are never finalized: a relayed mutation carrying an explicit :producer (see Mutare.Mutator.Mutation — it belongs to the producing family, whose own finalize/2 already ran when the mutation was generated), and a Mutare.Mutator.Structural hook's bare-AST replacements (they carry no metadata to finalize). A target whose mutants all return :skip is dropped entirely.

A mutator without finalize/2 records mutations as returned.

init(opts)

(optional)
@callback init(opts :: term()) :: term()

Parses the instance's options into its normalized configuration, once per resolved instance.

Called when a :mutators entry is resolved to a Mutare.Mutator.Spec — before the transform reads any file — with the instance's raw options (the opts of a {module, opts} entry, :as already stripped; [] for a bare module). Raise for invalid options: this is where a typo'd option fails loudly, at startup, rather than on the first mutated node.

The return value is delivered to every context-aware callback as context.configmutate/2, the context-aware Mutare.Mutator.Structural arities, and Mutare.Mutator.MacroHost.host/2. context.opts continues to carry the raw options. A mutator without init/1 gets context.config == context.opts.

A module listed more than once (the documented multi-instance :as pattern) runs init/1 once per instance, each call receiving that entry's own options. Mutare.CallRouting.route_arguments/2 is not config-aware: macro routing is shared by every mutator that meets the routed call, so its classification stays instance-independent by design (see Mutare.CallRouting).

mutate(t)

(optional)
@callback mutate(Macro.t()) :: :skip | [mutation()]

Produces node-level mutations for node.

Return :skip when the mutator does not apply. Otherwise return one mutation/0 entry per mutant. This callback is optional when a mutator produces mutations only through mutate/2 or Mutare.Mutator.Structural.

Transform-managed families such as :guard_drop and :rescue_type are registered for configuration and reporting but do not implement this callback.

mutate(t, context)

(optional)
@callback mutate(Macro.t(), context()) :: :skip | [mutation()]

Produces context-aware mutations for node.

This callback is used for pipe-aware, configurable, and behaviour-targeted mutators. context.pipe_mode lets a mutator compute effective arity for piped calls; context.opts carries per-instance configuration; context.behaviours carries the enclosing module's behaviour set.

When both mutate/1 and mutate/2 are exported, this callback takes precedence. Mutare does not also call mutate/1. To compose them, call mutate/1 from mutate/2 and combine the results explicitly. The return shape is the same as mutate/1.

mutate_call_option_keys?(opts)

(optional)
@callback mutate_call_option_keys?(opts :: term()) :: boolean()

Controls mutation of trailing keyword-option keys.

The transform calls this after identifying a candidate that mutates a call option key such as timeout: in foo(timeout: 5). Return true to keep the candidate or false to suppress it. A mutator without this callback keeps the candidate.

The callback receives the mutator instance's configured options. It is separate from mutate/2 because option-key detection happens after the key node has already been offered to mutators.

name()

@callback name() :: atom()

Short family name, shown in reports (e.g. :arithmetic).

required_modules()

(optional)
@callback required_modules() :: [module()]

Declares modules that must be loadable for this mutator's routing and hosting to be valid.

A DSL plugin has a deployment requirement Mutare cannot infer: the library whose macros it routes (Mutare.CallRouting.call_routes/0) or hosts (Mutare.Mutator.MacroHost.hosted_macros/0) must be loadable in the Mutare process — otherwise its routes register against nothing and its mutations silently fail to fire. Declaring those modules here turns the silent degradation into a loud startup error: the check runs once, when the :mutators entry is resolved to a Mutare.Mutator.Spec (before init/1, before any source is read), and a missing module aborts the run with a Mutare.EnvironmentError naming the plugin, the missing modules, and the deployment requirement.

@impl true
def required_modules, do: [Ecto.Schema, Ecto.Query]

Loadability (Code.ensure_loaded?/1) is the whole check — it does not verify that a module's application is started or that its version is compatible. A plugin with a requirement beyond loadability raises its own descriptive error from init/1 (or from Mutare.CallRouting.call_routes/0).

A non-mutating extension may export the same function — capability discovery is by export, so Mutare.Extension.validate!/1 applies the same check to :extensions entries. A module without this callback is assumed environment-independent.

variant(original, mutated)

(optional)
@callback variant(original :: Macro.t(), mutated :: Macro.t()) ::
  String.t() | atom() | [String.t() | atom()] | nil

Derives variant labels from an emitted {original, mutated} pair.

Return one label, a list of labels, or nil. Labels must be members of variants/0 and are matched case-insensitively by ignore directives.

Classify from both nodes, not the mutated node alone. For example, a strip mutation such as -(a + b)a + b emits a + node but is not an operator swap. Operator families can use op_swap_variant/3 for this pattern:

# in Mutare.Mutators.Relational
@swap_ops [:>, :>=, :<, :<=, :==, :!=, :===, :!==]
def variants, do: Enum.map(@swap_ops, &to_string/1)
def variant(original, mutated),
  do: Mutare.Mutator.op_swap_variant(original, mutated, @swap_ops)

variants()

(optional)
@callback variants() :: [String.t() | atom()]

Declares the variant labels this mutator supports in # mutare:ignore[family:label] filters.

A single source node may produce several sibling mutants. Variant labels allow a directive to suppress one kind without suppressing the whole family.

A mutator that declares variants assigns labels in one of two ways:

  • Tag at production — return a Mutare.Mutator.Mutation.tagged(node, label) from mutate/1/mutate/2, attaching the label where the mutant is built. Best when the kind is known at construction (a value family: tagged(AST.literal(0), "zero")).
  • Derive afterwards — implement variant/2, which classifies the {original, mutated} pair. Best when the label reads cleanly off the node (an operator family: op_swap_variant/3 over the swapped operator).

A tagging mutator does not need variant/2. A mutator with no variant vocabulary supports only the bare [family] filter; a qualified filter against it is an error.

Labels must be non-empty wire-safe tokens: no whitespace, ,, (, ), ], or ". Treat labels as public API because users write them in source comments.

Functions

argument_marks_from(entries, label)

@spec argument_marks_from(term(), atom()) :: [
  {module(), atom(), arity(), [non_neg_integer() | {:keyword, atom()}], atom()}
]

Turns a list of {module, function, arity, positions} entries into argument_marks/1 declarations under label. positions is a list of effective argument indices and {:keyword, key} option keys, exactly as in a declaration; an index is validated against the declared arity. Raises ArgumentError with a pointed message on a malformed entry, so a typo fails at startup rather than silently marking nothing. The one-liner for a mutator that exposes its own "also leave these positions alone" option under its label.

declared_labels(mutators)

@spec declared_labels([Mutare.Mutator.Spec.t() | module()]) :: MapSet.t(atom())

The set of mark labels the given mutators declare positions for (argument_marks/1) — the labels that mean something to some enabled family. Mutare.Options checks a configured argument_marks: entry's label against this set, so a typo'd or orphaned label fails at startup instead of marking positions nobody reads.

iex> Mutare.Mutator.declared_labels([Mutare.Mutators.IntegerLiteral]) |> MapSet.member?(:timeout)
true

effective_arity(args, atom)

@spec effective_arity([Macro.t()], pipe_mode()) :: non_neg_integer()

Returns the effective arity of a call under its pipe context.

A piped call stage has one implicit argument: the left side of the pipe. That argument is not present in the call node's own argument list, so piped arity is length(args) + 1.

iex> Mutare.Mutator.effective_arity([:a, :b], :unpiped)
2
iex> Mutare.Mutator.effective_arity([:b], :piped)
2

marked?(arg1, label)

@spec marked?(context(), atom()) :: boolean()

Whether the node being offered carries the position mark label — i.e. sits at a position some mutator requested via argument_marks/1. The reader half of the marking facility: a mutate/2 checks this and returns :skip (or adapts) at a marked position.

def mutate(node, context) do
  if Mutare.Mutator.marked?(context, :timeout), do: :skip, else: mutate(node)
end

Total over a context with no marks (the common case).

op_swap_variant(arg1, arg2, ops)

@spec op_swap_variant(Macro.t(), Macro.t(), [atom()]) :: String.t() | nil

Classifies a binary operator swap for variant/2.

When original and mutated are both two-argument operator nodes whose heads are in ops, returns the new operator as a string. Otherwise returns nil. Checking both nodes prevents unary strip mutations from being classified as binary swaps.

def variant(o, m), do: Mutare.Mutator.op_swap_variant(o, m, @swap_ops)

validate_argument_marks!(entries)

@spec validate_argument_marks!(term()) :: [
  {module(), atom(), arity(), [non_neg_integer() | {:keyword, atom()}], atom()}
]

Validate a user-facing list of argument-mark declarations — the argument_marks: option in .mutare.exs, whose entries are exactly what argument_marks/1 returns: {module, function, arity, positions, label}. Shape-checked here (a bad index or a non-atom label fails at startup); whether label is one some configured mutator declares positions for is checked once the mutator set is known (Mutare.Options.new/1).

iex> Mutare.Mutator.validate_argument_marks!([{MyApp.Http, :get, 2, [{:keyword, :recv_timeout}], :timeout}])
[{MyApp.Http, :get, 2, [{:keyword, :recv_timeout}], :timeout}]

visible_index(pos, atom)

@spec visible_index(non_neg_integer(), pipe_mode()) :: non_neg_integer() | nil

Converts an effective argument index to the index in the call node's visible argument list.

In piped calls, effective index 0 is the pipe's left side and has no visible index, so the function returns nil. Later indexes shift down by one. In unpiped calls, effective and visible indexes are the same.

iex> Mutare.Mutator.visible_index(2, :unpiped)
2
iex> Mutare.Mutator.visible_index(0, :piped)
nil
iex> Mutare.Mutator.visible_index(1, :piped)
0