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
:skip_ids, so ids stay stable across the poison-recovery rebuilds the runner relies on. - 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. - Render — annotations are stripped and the tree is rendered to source
(with a Sourceror keyword-block workaround);
# mutare:ignoredirectives (parsed byMutare.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
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.
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 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.CaseClauseEmit— the tuple-the-scrutinee delivery for per-clausecasepattern/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
@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 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.
@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):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 prints once —Mutare.Schema's render phase (the count phase warned) andrender_sites/2(report-time re-derivation).