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 redundantcontext/kind/operationtriple (and its illegal combinations) is gone.
The stages, run per subtree:
- Analyze + classify —
analyze/3is 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 typedCandidate.InPlaceto 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 flatMacro.traverseaccumulator. 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 andsize()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 explicitdefbody, which flips back to:runtime); the rest (:compile_time,:spec,:guard,:capture_arity) are recognised and pruned, producing no candidate. - Plan — a statement sequence is grouped into a
ModulePlan; each liftable clause group becomes aFunctionPlancarrying its lifted candidates. No ids are assigned yet. - 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 ignored, unselected, and
:skip_idsmutants. Ignore directives are matched against each recorded site's line, family, and variant before deciding whether to emit its code. - Emit — an in-place candidate becomes a tail-position selector
case; aFunctionPlanbecomes one private function (threading the active id as an extra arg) behind a dispatcher, each mutant a single guarded clause. Ignored, unselected, and poisoned mutants retain their sites but emit no code. A function holding two or more selector sites also keeps its source beside the instrumented code, as a clean region: a lifted group gains a copy of its original clauses, a clause that stays in place a second:dobody, and one decision on the already-read id sends a mutation elsewhere to the source. Baseline/probe and the region's full id interval retain the instrumented path. Any source may be copied: a copy that fails to compile is attributed to its region, which poison recovery then drops (:skip_regions). A copied call still enters its callee through that callee's own dispatcher. - Render — annotations are stripped and the tree is rendered to source (with a Sourceror keyword-block workaround).
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
endSubstituting 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.
A pipe stage is analyzed as the call it is sugar for (left |> stage(args) as
stage(left, args) — WrittenPipe), so no selector ever lands on the
right side of a pipe, where a case is illegal. Emission binds the piped value once, in a
one-shot closure invoked on it (PipeEmit):
lhs |> (fn v -> case … (each branch pipesv) … end).(). 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. The
Site still records the stage as written, so the diff is unchanged.
The closure evaluates the piped value ahead of the stage, as an ordinary call does its first
argument — and a call is ordinary in every respect its route does not address
(Mutare.CallRouting, "Ordinary calls"). So it is used when the stage's first position is a
value (unrouted, :expression or :interior); one routed :lazy_expression, or as syntax,
is never evaluated ahead of the call. A mutant that moves or drops the piped value goes in a
selector around the closure instead, evaluating its own expression in its own order. No
stage reaches emission as a pipe: the rewritten calls are direct calls in the emitted tree
(their mutant branches hold the as-written arguments, the catch-all the emitted ones), and
Render spells each as the pipe it was written as. Code left as written — a :raw
argument, a :skipped call — keeps its pipe untouched. Only Kernel's |> is treated as a
pipe at all; one a module displaced is a call to that module's operator.
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.
Implementation modules
Mutare.Transform.{ModulePlan,FunctionPlan,Candidate} define the intermediate representation —
the plan structs and pure discovery (chunking clauses, finding guard/drop
candidates). This module implements the top-level emission traversal and coordinates function
lifting; focused delivery modules implement 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 shareddef/defpclause shape and the primitives that navigate it (head/args/guards/when), used by both this module andFunctionPlan.Mutare.Transform.GuardBuild— the dispatch guards (<var> === <id>gate, exclusion,and-into), shared by the lifted andcasepaths.Mutare.Transform.LiftedEmit— the dispatcher + gated base clauses for a lifted group (the assembly half ofemit_function_plan/2).Mutare.Transform.CleanRegion— the one decision that sends a mutation elsewhere to a function's uninstrumented source, the policy for when that repays its code, and the reader that lets a failing copy be blamed on its region.Mutare.Transform.CaseClauseEmit— the tuple-the-scrutinee delivery for per-clausecasepattern/guard mutants.Mutare.Transform.FnClauseEmit— per-clause anonymous-function delivery, capturing the selector and recording head/guard coverage at closure creation.Mutare.Transform.ReceiveClauseEmit— per-clause receive delivery, preserving native mailbox order and one timeout evaluation; clause guards are shared with anonymous functions throughMutare.Transform.ClauseVariants.Mutare.Transform.ClauseGuardEmit— guard-only delivery for the clauses that can host no extra clause (with/for<-,with/tryelse,trycatch,for … reduce:do): the guard becomes a gated guard sequence in place.Mutare.Transform.RescueEmit— shares protected bodies across rescue mutants when native handler dispatch can reuse an existing exception binding; other shapes retain whole-try selection.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.CoverageEmit— scope-aware coverage gate emission and binding dependencies; delivery paths retain ownership of recording positions.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
@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.
@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. 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.
@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, orMutare.Mutator.Specs); defaults to the full built-in set:call_routes— list of call-route entries ({module, name, arity, treatment}/{module, name, treatment}, seeMutare.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 shapeMutare.Mutator.argument_marks/1returns) marking extra positions for the mutators that readlabel; merged with the enabled mutators' own declarations. Defaults to[].:extensions— list of non-mutating extension modules, each implementingMutare.CallRouting,Mutare.UseExpansion, or both; entries may be bare modules or{module, opts}pairs. Static routes merge into the registry andexpand_use/3overrides feeduse-expansion (the extension'soptsride along toexpand_use/3's context). Defaults to[].:start_id— first mutant id to assign (default1):runtime_namespace— internal schema plumbing: when set to a nonempty file string, emit local ids from one under that namespace while Sites and:skip_ids/:emit_idsretain the:start_idreport range. Omitted by the public standalone path, which keeps integer runtime selection.:expand_uses— whentrue(the default), expand module-levelusestatements with static args and feed their injectedimport/aliasdirectives into resolution (seeMutare.Transform.Uses);falsefreezes the pre-expansion behaviour (and, with it, any extensionuse-expansion overrides):warnings— whentrue(the default), print advisory warnings: suspect but non-fatal extension behaviour (currently: a:routingclassifier returning{:keyword, …}for a non-keyword argument) and the lifting advisories (a:skip_liftingmatch, non-consecutive / metaprogrammed / delegated clause groups). Callers that re-run the pipeline over a source already scanned passfalseso each warning is printed once —Mutare.Schema's render phase (the count phase warned) andrender_sites/2(report-time re-derivation).:verify_invariants— whentrue, check the result before returning it and raiseMutare.InvariantErrorlisting every violation: the rendered metamutant must parse; every mutant the result records as live must have a branch that runs when it alone is active, and a coverage record; the generated code must name no other id; no live mutant may render identically to its original; and emitting the source a second time must reproduce the first pass. Meant for developing mutators, hosts, and extensions; the checks add roughly a sixth to the transform's cost, since the second pass stops before the render. Defaults tofalse(Mutare.Test's source helpers default it totrue).