Mutare.Coverage.Recorder (mutare v0.1.0)

Copy Markdown View Source

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 owns how it is produced. There are three pieces, all emitted into generated code:

  • record_ast/1 — spliced by Mutare.Transform into 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.Selector owns the selection contract the same way.)
  • helper_source/0 — a dependency-free helper module (Mutare.Sandbox writes 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.
  • setup_ast/0 and after_suite_ast/0 — injected around the target's test_helper.exs; when the probe env var is set, setup creates the tables and flips the tracking flag before user helper code runs, while the after-suite hook is registered after the helper has started ExUnit.

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 is mutare_active == 0 and :persistent_term.get(:mutare_track, false) and <helper>.hit([<ids>]):

  • per-mutant runs (mutare_active != 0) short-circuit on the integer compare — ~zero hot-loop cost;
  • the baseline green run and Mutare's own unit tests (:mutare_track unset) short-circuit on the persistent-term read — the helper is never called;
  • only the probe run (MUTARE_COVERAGE set → :mutare_track true, mutare_active == 0) records.

The helper's hit/1 returns true so the and chain stays boolean (an :ets call returns an int/true and would raise BadBooleanError mid-and).

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 module name Mutare's test/support/mutare_cov.ex stand-in defines itself as.

Env var a sandbox run sets to give the suite-under-test's stand-in a private module name.

The dependency-free helper module emitted into the sandbox.

Source for the dependency-free coverage helper written into the sandbox.

AST for the @compile {:no_warn_undefined, {<helper>, :hit, 1}} attribute.

AST that records coverage for this selector's mutant ids.

Return the dispatch variable read by a coverage record, or nil.

Env var the probe sets to the absolute root test-file paths are relative to.

Setup AST prepended before the target's test helper.

The private stand-in module name (fixture_override_env/0's value) used under dogfooding.

The :persistent_term flag the spliced record reads (false by default).

The canonical selector-subject variable name (:mutare_active); the default var.

Functions

after_suite_ast()

@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_pattern(var \\ :mutare_active)

@spec catch_all_pattern(atom()) :: Macro.t()

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.

dump_file()

@spec dump_file() :: String.t()

The file (relative to the sandbox) the end-of-suite dump is written to.

dump_path_env()

@spec dump_path_env() :: String.t()

Env var the probe sets to the absolute path the dump is written to.

env_var()

@spec env_var() :: String.t()

Env var the probe sets to turn coverage recording on for one run.

fixture_module()

@spec fixture_module() :: module()

The module name Mutare's test/support/mutare_cov.ex stand-in defines itself as.

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).

fixture_override_env()

@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.

helper_module()

@spec helper_module() :: module()

The dependency-free helper module emitted into the sandbox.

helper_source()

@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.

no_warn_attr_ast()

@spec no_warn_attr_ast() :: Macro.t()

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.

record_ast(ids, var \\ :mutare_active)

@spec record_ast([pos_integer()], atom()) :: 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.

Literal arguments use clean metadata. In particular, the 0 must be wrapped as {:__block__, [], [0]}; a bare integer can render badly when this expression is emitted as a statement in a generated function body.

record_var(arg1)

@spec record_var(Macro.t()) :: atom() | nil

Return the dispatch variable read by a coverage record, or nil.

This is the inverse of record_ast/2. A coverage record has this shape:

<var> == 0 and :persistent_term.get(<track_key>, false) and <helper>.hit(<ids>)

<var> is the file's dispatch variable, possibly salted. The internal <track_key> read identifies a real coverage record, so Mutare.Manifest can recover the dispatch name from rendered metamutant source without trusting a source-level binding that only looks similar.

The helper call is not part of recognition; that keeps self-hosting helper-name overrides from changing the result. Literal {:__block__, _, [literal]} wrappers added during reparse are accepted.

root_env()

@spec root_env() :: String.t()

Env var the probe sets to the absolute root test-file paths are relative to.

setup_ast()

@spec setup_ast() :: Macro.t()

Setup AST prepended before the target's test helper.

It is inert unless env_var/0 is set. During the coverage probe it creates the ETS tables and enables the tracking flag before user helper code runs, so app startup and helper setup can be attributed. The tables are owned by the test-helper process, which outlives the suite run.

suite_fixture_module()

@spec suite_fixture_module() :: String.t()

The private stand-in module name (fixture_override_env/0's value) used under dogfooding.

track_key()

@spec track_key() :: atom()

The :persistent_term flag the spliced record reads (false by default).

var_name()

@spec var_name() :: atom()

The canonical selector-subject variable name (:mutare_active); the default var.