Mutare.Transform (mutare v0.1.1)

Copy Markdown View Source

Source → metamutant transform, expressed as an explicit pipeline over a small intermediate representation.

Rather than walk every node and then subtract the positions that must not be mutated (the old blacklist), the transform classifies each node's context positively, builds a plan, then renders it. The plan is three typed pieces:

  • Mutare.Transform.ModulePlan — a statement sequence (a module body) classified into items: a clause group to lift, a clause group to keep in place, or any other statement. This is "module planning", separated from emission.
  • Mutare.Transform.FunctionPlan — one liftable clause group: its signature, its clauses, a single shared tagged clause group, and the typed lifted candidates (Candidate.Lifted / Candidate.Drop) it admits.
  • Mutare.Transform.Candidate — the typed, pre-id description of a single mutant. One struct per legal kind (see that module's moduledoc for the current set), so the redundant context/kind/operation triple (and its illegal combinations) is gone.

The stages, run per subtree:

  1. Analyze + classifyanalyze/3 is one context-threaded recursive descent: it names the context of each position as it descends and, for every node a mutator recognises in a mutating context, attaches a typed Candidate.InPlace to the node's own metadata (meta[:mutare]). Mutators run once, here. Routing is positional (the spec side of a :: goes one way, the value side another), which is why it can't be a flat Macro.traverse accumulator. Three contexts are threaded — :runtime (mutate, → in-place; :guard/:clause_drop/head-pattern literals come from the lift path), :pattern (don't mutate in place, but keep descending so default-arg values and size() args are reached), and :scaffold (a known compile-time module-level statement: descend but never mutate its own expressions — they run once at compile time, so a selector there is inert — yet still reach any explicit def body, which flips back to :runtime); the rest (:compile_time, :spec, :guard, :capture_arity) are recognised and pruned, producing no candidate.
  2. Plan — a statement sequence is grouped into a ModulePlan; each liftable clause group becomes a FunctionPlan carrying its lifted candidates. No ids are assigned yet.
  3. Assign — emission walks the plan and the annotated tree bottom-up and hands each candidate the next mutant id. Ids are assigned in post-order DFS and the counter advances even for :skip_ids, so ids stay stable across the poison-recovery rebuilds the runner relies on.
  4. Emit — an in-place candidate becomes a tail-position selector case; a FunctionPlan becomes one private function (threading the active id as an extra arg) behind a dispatcher, each mutant a single guarded clause.
  5. Render — annotations are stripped and the tree is rendered to source (with a Sourceror keyword-block workaround); # mutare:ignore directives (parsed by Mutare.Ignore) are applied to the recorded sites.

Carrying the Candidate.InPlace in the node's own metadata is what lets emission find "this exact node" without a fragile {line, column} identity: metadata is intrinsic to the node and rides through any Macro rebuild, so duplicate subtrees can never collide.

In-place selector (body expressions)

An operator inside a body is wrapped in a tail-position case reading the active mutant id from :persistent_term:

# source:   total >= threshold
case :persistent_term.get(:mutare_active, 0) do
  17 -> total > threshold     # mutant 17:  >= → >
  _  -> total >= threshold    # baseline + every other mutant
end

Substituting a node with a value-equivalent case preserves its position, so tail calls stay tail calls (LCO). Nested sites work because the catch-all holds the transformed children, reachable whenever an outer mutant is inactive.

One position the selector case is not legal in: the right side of a pipe. x |> case … end parses but fails to compile (Kernel.|>/2 cannot pipe into a case), so when a mutated node is a pipe stage, emission lifts the selector out of the pipe into a one-shot closure invoked on the piped value (PipeEmit.hoist/2): lhs |> (fn v -> case … (each branch pipesv) … end).(). The piped value is computed once (it stays the pipe's LHS) and bound to v, so each branch references a cheap variable — keeping a chain of mutated stages linear in the rendered source, where distributing lhs into every branch would copy the whole upstream chain per branch and blow up exponentially. The Site still records the bare stage, so the diff is unchanged.

Function lifting + dispatcher (guards, dispatch)

A case is illegal in a when guard, and guards drive dispatch across clauses, so guard mutations (and head-pattern / clause-drop mutations) cannot be done in place. Instead the whole clause group becomes one private function that takes the active mutant id as an extra first arg (mutare_active); the public f/arity becomes a dispatcher that reads the id and forwards. Each source clause is emitted once as an original gated when mutare_active !== <id> for every mutant that overrides/drops it; each mutant adds a single clause gated when mutare_active === <id>, placed before the original it replaces:

def f(a) do
  mutare_active = :persistent_term.get(:mutare_active, 0)
  __mutare_f_1_g1(mutare_active, a)
end
defp __mutare_f_1_g1(mutare_active, a) when mutare_active === 5 and a > 1, do: ...  # mutant 5: guard >= → >
defp __mutare_f_1_g1(mutare_active, a) when mutare_active !== 5 and a >= 1, do: ... # original (in-place applies here)

Exactly one clause wins for any (id, args): the mutant when its id is active and its head/guard match, else the original. This is per-clause: a mutant touching one clause no longer copies the other N−1, so a group with C clauses and M mutants emits ~C+M clauses, not C×M (see NOTES "lifting blowup"). In-place selectors live only in the original clauses (and non-lifted code); a mutant clause reuses the raw body — sound because exactly one mutant is ever active. The public f/arity is unchanged at the module boundary.

Ranges are captured against the original AST, which is what the diff report patches against.

Where the work lives

Mutare.Transform.{ModulePlan,FunctionPlan,Candidate} own the vocabulary — the plan structs and pure discovery (chunking clauses, finding guard/drop candidates). This module owns the top-level emission walk and the lifted-function orchestrator; focused delivery modules own the smaller specialized paths, with shared selector mechanics factored through SelectorEmit.

Focused helper modules keep the pure node-building and the smaller specialized delivery paths out of this file:

  • Mutare.Transform.ClauseAST — the shared def/defp clause shape and the primitives that navigate it (head/args/guards/when), used by both this module and FunctionPlan.
  • Mutare.Transform.GuardBuild — the dispatch guards (<var> === <id> gate, exclusion, and-into), shared by the lifted and case paths.
  • Mutare.Transform.LiftedEmit — the dispatcher + gated base clauses for a lifted group (the assembly half of emit_function_plan/2).
  • Mutare.Transform.CaseClauseEmit — the tuple-the-scrutinee delivery for per-clause case pattern/guard mutants.
  • Mutare.Transform.BindingEscapeEmit — the tuple-export delivery for binding escaping = matches and known macros.
  • Mutare.Transform.HostedEmit — the selector-host delivery for hosted DSL fragments.
  • Mutare.Transform.ImportWitness — the dead-code import witness spliced alongside a mutated bare imported call.
  • Mutare.Transform.SelectorEmit — the shared id/site claim, selector subject, catch-all coverage branch, and ordinary selector-case assembly.

Summary

Functions

Count the mutants a source would produce, without rendering the metamutant.

Rebuild a source's sites with original_code and mutated_code populated.

Transform a source string into a stable public result DTO.

Functions

count_string(source, opts \\ [])

@spec count_string(
  String.t(),
  keyword()
) :: non_neg_integer()

Count the mutants a source would produce, without rendering the metamutant.

The count is the number of ids claimed by transform_string/2: next_id - start_id, which is also the number of returned public mutants. It is computed by running the same analysis, planning, and emit pipeline as transform_string/2, but without rendering the final metamutant source.

Mutare.Schema uses this for its two-phase build. First it counts each file, then assigns each file a stable :start_id, then renders files in parallel. The count does not depend on the caller's :start_id or :skip_ids; skipped ids still claim their position so the later render assigns the same span.

Accepts the same options as transform_string/2 and raises the same parser exceptions for invalid source.

render_sites(source, opts \\ [])

@spec render_sites(
  String.t(),
  keyword()
) :: [Mutare.Site.t()]

Rebuild a source's sites with original_code and mutated_code populated.

This skips metamutant rendering and ignore application. It exists for deferred diff rendering: a mix mutare scan can set :render_site_code to false, then Mutare.Runner.Hydrate can call this later for the few sites that need to be displayed.

Call it with the same options used for the original scan, including the file's :start_id. Because the transform is deterministic for one source, the ids and rendered code match an eager transform_string/2 run. This function always enables :render_site_code, regardless of the caller's option value.

Accepts the same options as transform_string/2 and raises the same parser exceptions for invalid source.

transform_string(source, opts \\ [])

@spec transform_string(
  String.t(),
  keyword()
) :: Mutare.Transform.Result.t()

Transform a source string into a stable public result DTO.

Returns %Mutare.Transform.Result{} with the rendered metamutant source, public mutant descriptions, and next_id.

next_id is the first mutant id left unassigned — what the next file in a schema should start from. It equals :start_id when nothing was mutated, so the caller never has to recover it from the last site.

Options:

  • :file — path recorded on each site (default "nofile")
  • :mutators — list of mutator entries (family atoms, modules, {module, opts} pairs, or Mutare.Mutator.Specs); defaults to the full built-in set
  • :call_routes — list of call-route entries ({module, name, arity, treatment} / {module, name, treatment}, see Mutare.CallRouting) that route a call's arguments specially, or skip the call outright; merged with the built-ins and routing capabilities on enabled mutators/extensions. Defaults to [].
  • :argument_marks — list of {module, function, arity, positions, label} declarations (the shape Mutare.Mutator.argument_marks/1 returns) marking extra positions for the mutators that read label; merged with the enabled mutators' own declarations. Defaults to [].
  • :extensions — list of non-mutating extension modules, each implementing Mutare.CallRouting, Mutare.UseExpansion, or both; entries may be bare modules or {module, opts} pairs. Static routes merge into the registry and expand_use/3 overrides feed use-expansion (the extension's opts ride along to expand_use/3's context). Defaults to [].
  • :start_id — first mutant id to assign (default 1)
  • :expand_uses — when true (the default), expand module-level use statements with static args and feed their injected import/alias directives into resolution (see Mutare.Transform.Uses); false freezes the pre-expansion behaviour (and, with it, any extension use-expansion overrides)
  • :warnings — when true (the default), print advisory warnings: suspect but non-fatal extension behaviour (currently: a :routing classifier returning {:keyword, …} for a non-keyword argument) and the lifting advisories (a :skip_lifting match, non-consecutive / metaprogrammed / delegated clause groups). Callers that re-run the pipeline over a source already scanned pass false so each warning prints once — Mutare.Schema's render phase (the count phase warned) and render_sites/2 (report-time re-derivation).