Mutare.Manifest (mutare v0.3.1)

Copy Markdown View Source

A map from mutant ids to their generated line ranges in a rendered metamutant, and a list of every place the generated code names a mutant id.

Mutare.Transform writes the metamutant; this is what Mutare.Poison reads back. It is built lazily (from_source/2), by Mutare.Poison on a failed compile, only for the file(s) the error names — not eagerly during the scan, where re-parsing every rendered metamutant was the scan's dominant cost yet is read only when a compile actually fails (rare — built-in mutators are compile-safe). Poison memoizes it within one recovery so a file faulting on several lines is walked once.

The one exception is verify_invariants: true (mix mutare --verify-invariants): the transform then builds a manifest for every file it renders and compares its :mentions with the mutants it recorded, raising Mutare.InvariantError on a mismatch.

Mutare.Poison uses each mutant's generated ranges — the metamutant line spans of the code that exists only because of that mutant, so a compile error's line maps back to the corresponding mutant id.

(Coverage is recorded separately: the metamutant self-records coverage at runtime — see Mutare.Coverage.Recorder — keyed by mutant id directly, so there is no metamutant {module, line} location to precompute.)

Why ranges, not a single line

Poison recovery used to match a compile error's line against the start line of a selector clause body only. That missed every poison whose bad code isn't on that exact line:

  • lifted mutations — a guard/head-pattern mutant's code appears in a generated defp <base>(mutare_active, …) when mutare_active === <id> … clause, not in the dispatcher (which only forwards). The error points into that gated clause, away from the dispatcher;
  • multiline bodies — an in-place mutant whose body spans several lines can fault on any of them, not just the first;
  • structural errors — the compiler sometimes points at the surrounding case rather than a clause body.

So we record, per mutant, the full line ranges of its generated code:

  • each selector clause body (<id> -> <mutated>) — catches in-place mutants, including multiline bodies;
  • each lifted mutant clause (gated when mutare_active === <id>) — catches a guard/head-pattern poison, whose code is outside the dispatcher. A clause shared by several guard mutants (one when alternative each) gives every alternative to its own mutant, and the whole clause — head patterns and the one shared body — to all of them;
  • the whole selector case, attributed to all the mutant ids it hosts — the coarse fallback for a structural error that points at the case itself.

ids_at_line/2 resolves an error line by narrowest containing range: a line inside a specific clause/def range maps to that one mutant; only when nothing narrower contains it does the whole-case fallback fire (dropping every mutant the case hosts — a bounded over-drop that still recovers the build, never the old "couldn't map it → abort").

Ranges are in metamutant line space, which only exists after rendering, so the manifest is built by re-parsing the rendered metamutant and ranging its generated nodes with Sourceror.get_range/1, recognising selectors via Mutare.Metamutant.subject?/2. A selector inside a function body reads the file's dispatch variable rather than :persistent_term directly, and a lifted or tupled mutant clause is gated on it (<var> === <id>) — so that name must be supplied to the reader. It is per file (:mutare_active, or a salted variant when the source already uses that identifier), chosen by the transform and handed out with the metamutant (Mutare.Transform.Result.dispatch_var, Mutare.Schema's :dispatch_vars); from_source/2 takes it alongside the source.

The re-parse uses Elixir's own Code.string_to_quoted! (with :token_metadata

  • :columns), not Sourceror.parse_string!. get_range/1 only needs that token metadata (:line/:column/:closing/:end/:end_of_expression), which the stdlib parser already produces; what Sourceror.parse_string! adds is a comment-merging pass that is quadratic on a large metamutant (a lifted 1.8 MB file re-parses in ~0.6 s here, versus minutes for Sourceror) and that the manifest never reads. A :literal_encoder reproduces Sourceror's {:__block__, meta, [literal]} wrapping so the recognisers — already tolerant of both shapes — accept the resulting nodes; the two parses yield identical ranges. (Sourceror is still the renderer; only this readback parse changed.)

Summary

Types

One place where the generated code names a mutant id

A generated line range and the mutant ids whose code occupies it.

t()

One file's manifest

Functions

Build a manifest from one file's rendered metamutant source and its dispatch variable.

Mutant ids whose generated code occupies line.

Mutant ids inside a call to one of names, grouped by that call's function name.

Types

mention()

@type mention() :: %{
  kind: :branch | :exclusion | :record,
  id: pos_integer(),
  within: [pos_integer(), ...] | nil
}

One place where the generated code names a mutant id:

  • :branch — code that runs only while id is active: a selector clause body, a gated clause (lifted, tupled, fn, receive), or a gated guard alternative;
  • :exclusion — an original clause stepping aside while id is active (<var> =/= <id>, or the range form for a run of ids). A dropped clause leaves only this trace;
  • :record — a coverage record listing id.

within names the innermost mutant branch enclosing the mention — the ids under which that branch runs — or is nil outside every branch. It is a list because a clause shared by several guard mutants runs its one body under any of them; every other branch has one id. Code inside a branch runs only while one of its ids is active, so a run that activates id alone reaches the mention only when within is nil or holds id.

region()

@type region() :: %{ids: [pos_integer()], lo: pos_integer(), hi: pos_integer()}

A generated line range and the mutant ids whose code occupies it.

t()

@type t() :: %Mutare.Manifest{
  ast: Macro.t() | nil,
  mentions: [mention()],
  regions: [region()]
}

One file's manifest:

  • :regions — generated line ranges, for mapping a compile error back to a mutant.
  • :mentions — every generated mention of a mutant id, in source order, for Mutare.Transform.Invariants to check against the mutants the transform recorded.
  • :ast — the parsed metamutant the regions were ranged over, retained so the macro-expansion fallback (ids_in_named_calls/2) can range a blamed macro's calls in the same tree rather than parse the file a second time. nil on a hand-built manifest.

Functions

from_source(metamutant_source, dispatch_var)

@spec from_source(String.t(), atom()) :: t()

Build a manifest from one file's rendered metamutant source and its dispatch variable.

Re-parses (for Sourceror.get_range/1) and walks the tree once, attributing every selector clause, lifted private definition, and selector case to the mutant id(s) it belongs to, and collecting every mention of an id (mention/0). dispatch_var is the name the transform chose for this file (Mutare.Transform.Result.dispatch_var): the hoisted selectors read it as their subject and the gated mutant clauses compare it, so it is what makes them recognisable. The parse is kept on the manifest (:ast): one failed compile can need both attributions of the same file, and Mutare.Poison holds one manifest per file for the round.

Examples

iex> source = "defmodule Demo do\n  def add(a, b), do: a + b\nend\n"
iex> result = Mutare.transform_string(source, mutators: [:arithmetic])
iex> %Mutare.Manifest{regions: regions} =
...>   Mutare.Manifest.from_source(result.metamutant, result.dispatch_var)
iex> regions == []
false

ids_at_line(manifest, line)

@spec ids_at_line(t(), pos_integer()) :: [pos_integer()]

Mutant ids whose generated code occupies line.

Resolved by narrowest containing range: a specific clause/def range beats the coarse whole-case fallback, so a precise error drops exactly the offending mutant; a structural error that only the case range contains drops every mutant it hosts. Returns [] when no generated code spans line.

Examples

iex> manifest = %Mutare.Manifest{
...>   regions: [
...>     %{ids: [1, 2], lo: 3, hi: 8},
...>     %{ids: [1], lo: 5, hi: 5}
...>   ]
...> }
iex> Mutare.Manifest.ids_at_line(manifest, 5)
[1]
iex> Mutare.Manifest.ids_at_line(manifest, 4)
[1, 2]
iex> Mutare.Manifest.ids_at_line(manifest, 99)
[]

ids_in_named_calls(manifest, names)

@spec ids_in_named_calls(t(), MapSet.t(atom())) :: %{optional(atom()) => MapSet.t()}

Mutant ids inside a call to one of names, grouped by that call's function name.

The macro-expansion fallback's attribution (Mutare.Poison.macro_poison/4): when a mutation splices a selector case into an argument a macro rewrites at compile time, the macro raises during expansion and the compiler reports the macro call line — which no region covers — so ids_at_line/2 finds nothing. Given the macro name from the compiler's expanding macro: frame, this instead finds every call of that name in the rendered metamutant, takes its full line range (Sourceror.get_range/1, to the closing delimiter — so a literal argument on its own line is still spanned), and collects the ids of every region contained in it.

Works in metamutant space: the rendered source is what the compiler read, so the ids found inside a blamed macro's span are exactly the ones that could have poisoned it — no mapping back to original-source coordinates is needed, or possible. Reads the manifest's retained :ast (a from_source/2 manifest, whose regions were ranged with the file's dispatch variable), so the file is parsed once for both attributions. Returns %{fun_atom => MapSet.t()}, empty when nothing matched.