The generated-code side of coverage capture: the contract the metamutant and the test bootstrap share to record coverage synchronously, in the test process, with no race.
Mutare.Coverage reads what this produces; this module defines how it is
produced. There are three pieces, all emitted into generated code:
record_ast/1— spliced byMutare.Transforminto every selector catch-all. At baseline, under a tracking flag, it records the site's mutant ids into shared ETS — in whatever process runs the line, including the test process. (Mutare.Selectordefines the selection contract the same way.)helper_source/0— a dependency-free helper module (Mutare.Sandboxwrites it into the sandbox) holding the ETS writes and the end-of-suite dump, so the per-site code stays a single call and the OTP-version-tolerant label read lives in one place.- Lifecycle ASTs —
mode_ast/1initializes probe mode in themix.exsprefix, before target project/config/application code.tables_ast/1creates tables and enables recording before usertest_helper.exscode.after_suite_ast/0registers the dump after the helper starts ExUnit. Both scoped ASTs take:harnessor:fixtureexplicitly — there is no default, because a bootstrap's scope is a property of where it is written, not of the environment of the process that rendered it.
Probe mode (:mutare_probe) is immutable after initialization. Recording
readiness (:mutare_track) becomes true only after table setup. They have
different lifetimes: a function or closure can span table setup, so only mode
is a candidate for hoisting. Keeping readiness in the per-site gate also keeps
early target code from calling the helper before it is loadable. The helper
checks table existence again for direct calls and fixture table replacement.
The compiled HelperTemplate uses private fixture tables, process-dictionary
caches and dump variables. Under self-hosting, the existing helper-name override
also selects the private recording key for fixture metamutants; generated harness
bootstraps always use the harness descriptor. Fixture setup cannot alter the
outer probe, and its table-owning tests can run during that probe.
Why this, not :cover
:cover's counters live in a single global table keyed {module, line} — there
is no per-process partition, so attributing coverage to a test in one run
needs a snapshot at each test boundary, and the only global per-test signal
ExUnit emits is an async cast to formatters that races test execution
(fast async: false modules' coverage is lost). The metamutant, by contrast,
is code we generate and that runs synchronously in the test process. So the
capture lives there, accumulate-only (never reset), keyed by mutant id directly.
The gate (why it is inert outside the probe)
The spliced expression (record_ast/3) is two nested short-circuits:
case (case :erlang."=:="(mutare_active, 0) do
true -> :persistent_term.get(:mutare_track, false)
_ -> false
end) do
true -> <helper>.hit([<ids>])
_ -> false
end- per-mutant runs (a local id in the selected file,
:inactiveelsewhere) short-circuit on the comparison with zero — ~zero hot-loop cost; - the baseline green run and Mutare's own unit tests (recording disabled) short-circuit on the persistent-term read — the helper is never called;
- only the probe run (
MUTARE_COVERAGEset →:mutare_tracktrue,mutare_active == 0) records.
They are cases, not ands: the record is spliced into the target's own modules,
where a narrowed or replaced Kernel import would redefine and under it, and
:erlang.andalso — the form and compiles to in a guard — is undefined in a body.
A special form is the one thing no import can redirect. The comparison is an explicit
:erlang call for the same reason, and neither case can read as a hoisted
selector (case mutare_active do).
Schema emission supplies a namespace to record_ast/3, producing
hit(namespace, local_ids). The helper keeps a seen-cache per namespace and
qualifies an id as {namespace, local_id} only when recording it to ETS, so
local id 1 in two files stays two hits; the dump groups local ids by namespace.
Mutare.Coverage flattens them and maps them back to the current run's report
ids before test selection.
Summary
Functions
After-suite AST appended after the target's test helper.
Catch-all clause pattern that binds the selector subject to var.
The file (relative to the sandbox) the end-of-suite dump is written to.
Env var the probe sets to the absolute path the dump is written to.
Env var the probe sets to turn coverage recording on for one run.
The helper used by emitted fixture code and Mutare's test/support/mutare_cov.ex stand-in.
Env var a sandbox run sets to give the suite-under-test's stand-in a private module name.
The coverage gate, independent of the helper payload and its recognition.
The dependency-free helper module emitted into the sandbox.
Source for the dependency-free coverage helper written into the sandbox.
The helper call carrying this record's literal runtime ids.
Initialize immutable probe mode before target project code. Repeated project or umbrella helper evaluation leaves the initialized value intact. Harness code bakes the harness key; in-process fixture code uses its isolated runtime. No helper module or ETS table needs to exist yet.
AST for the @compile {:no_warn_undefined, {<helper>, :hit, 1}} attribute.
Recognise a gated coverage payload, independently of how its gate is evaluated.
AST that records coverage for this selector's mutant ids.
The runtime ids a gated coverage payload records, or :error for any other node.
Env var the probe sets to the absolute root test-file paths are relative to.
Capture state for one scope: :harness is what a generated sandbox bootstrap and
the written :mutare_cov helper use; :fixture is the disjoint set the compiled
HelperTemplate and Mutare's own in-process fixtures use, so fixture capture can
run inside an outer dogfood probe without touching it.
The private stand-in module name (fixture_override_env/0's value) used under dogfooding.
Create capture tables in the test-helper process, which owns them through the suite. Mode is already initialized; this phase never changes it. Umbrella helpers share one set of tables. Recording readiness is enabled only after creation. The helper also checks table existence dynamically for direct calls.
The recording-readiness key a spliced record reads.
The canonical selector-subject variable name (:mutare_active); the default var.
Functions
@spec after_suite_ast() :: Macro.t()
After-suite AST appended after the target's test helper.
ExUnit.after_suite/1 requires ExUnit to be started, so the dump callback is
registered after the user's helper even though coverage tracking starts before
it.
Catch-all clause pattern that binds the selector subject to var.
Mutare.Transform uses this instead of _ so record_ast/2 can read the
active id without a second :persistent_term lookup. Pass the same per-file
variable to this function and to record_ast/2.
@spec dump_file() :: String.t()
The file (relative to the sandbox) the end-of-suite dump is written to.
@spec dump_path_env() :: String.t()
Env var the probe sets to the absolute path the dump is written to.
@spec env_var() :: String.t()
Env var the probe sets to turn coverage recording on for one run.
@spec fixture_module() :: module()
The helper used by emitted fixture code and Mutare's test/support/mutare_cov.ex stand-in.
helper_module/0 (:mutare_cov) unless fixture_override_env/0 names another —
the one knob self-hosting needs so the stand-in does not collide with the real
helper the sandbox writes (see the constant's comment above).
@spec fixture_override_env() :: String.t()
Env var a sandbox run sets to give the suite-under-test's stand-in a private module name.
The coverage gate, independent of the helper payload and its recognition.
@spec helper_module() :: module()
The dependency-free helper module emitted into the sandbox.
@spec helper_source() :: String.t()
Source for the dependency-free coverage helper written into the sandbox.
The source comes from Mutare.Coverage.HelperTemplate, a normal
compile-checked module. Only its defmodule line is rewritten to
helper_module/0 (:mutare_cov).
The helper's hit/1 writes coverage to shared ETS tables. Its dump/1
callback, registered with ExUnit.after_suite/1, writes dump_file/0 and
maps each test module back to its source file.
@spec hit_ast([pos_integer()], String.t() | nil) :: Macro.t()
The helper call carrying this record's literal runtime ids.
@spec mode_ast(:harness | :fixture) :: Macro.t()
Initialize immutable probe mode before target project code. Repeated project or umbrella helper evaluation leaves the initialized value intact. Harness code bakes the harness key; in-process fixture code uses its isolated runtime. No helper module or ETS table needs to exist yet.
AST for the @compile {:no_warn_undefined, {<helper>, :hit, 1}} attribute.
Mutare.Transform prepends this to every metamutant module body. Selector
catch-alls call the generated coverage helper's hit/1; in an umbrella that
helper can live in a generated sibling app, so the mutated app may compile before
the helper and trigger a benign xref warning. The call resolves at runtime. This
attribute suppresses only that compile-time warning and is harmless when the
helper is compiled in the same app.
Recognise a gated coverage payload, independently of how its gate is evaluated.
The outer short-circuit case and the helper's literal positive-id payload
are the record contract. The condition may read tracking inline or use a bound
boolean. The tuple-selector reader separately verifies the selector identity and
the input/output variables; a coverage record alone never identifies a selector.
Both raw and literal-encoded reparsed ASTs are accepted.
@spec record_ast([pos_integer()], atom(), String.t() | nil) :: Macro.t()
AST that records coverage for this selector's mutant ids.
Mutare.Transform prepends this expression to a selector catch-all body. It
records only when the selector is at baseline and the coverage probe has enabled
tracking; see the moduledoc for the full gate.
var must be the variable introduced by catch_all_pattern/1. The AST is
built directly so it can share that binding and splice ids as a literal list
of integers.
namespace selects the helper's hit/2 form; nil retains standalone hit/1.
Literals use clean metadata (Mutare.AST.literal/1); a bare integer can render badly
when this expression is emitted as a statement in a generated function body.
The comparison is an explicit :erlang call and both short-circuits use case,
so target imports cannot redefine them and no guard-only :erlang.andalso call
appears in a body. record?/1 recognises the outer case and helper payload
without depending on the gate's implementation.
@spec recorded_ids(Macro.t()) :: {:ok, [pos_integer()]} | :error
The runtime ids a gated coverage payload records, or :error for any other node.
The reader half of hit_ast/2, under the same recognition as record?/1: a namespaced
payload yields its local ids (the namespace is the file's, which the caller already knows).
Mutare.Manifest reads it to check that every emitted mutant has a record.
@spec root_env() :: String.t()
Env var the probe sets to the absolute root test-file paths are relative to.
@spec runtime(:harness | :fixture) :: map()
Capture state for one scope: :harness is what a generated sandbox bootstrap and
the written :mutare_cov helper use; :fixture is the disjoint set the compiled
HelperTemplate and Mutare's own in-process fixtures use, so fixture capture can
run inside an outer dogfood probe without touching it.
@spec suite_fixture_module() :: String.t()
The private stand-in module name (fixture_override_env/0's value) used under dogfooding.
@spec tables_ast(:harness | :fixture) :: Macro.t()
Create capture tables in the test-helper process, which owns them through the suite. Mode is already initialized; this phase never changes it. Umbrella helpers share one set of tables. Recording readiness is enabled only after creation. The helper also checks table existence dynamically for direct calls.
@spec track_key() :: atom()
The recording-readiness key a spliced record reads.
This one follows fixture_module/0 rather than taking a scope, because it must
track whatever the emitter is emitting: a transform run in the harness bakes the
harness key into the metamutant, and a transform run by the suite-under-test (where
fixture_override_env/0 is set) bakes the fixture key into its fixtures. The
lifecycle ASTs are the other half of that contract and take their scope
explicitly (mode_ast/1, tables_ast/1) — they are written into generated
bootstraps, where the ambient override need not match the VM that will run them.
Don't pair this function with a lifecycle AST; reach for runtime/1 instead, whose
scope you have then named.
@spec var_name() :: atom()
The canonical selector-subject variable name (:mutare_active); the default var.