Mutare.Manifest (mutare v0.1.0)

Copy Markdown View Source

A per-mutant map of where each mutant lives in its rendered metamutant.

Mutare.Transform writes the metamutant; this is what Mutare.Poison reads back. It is built lazily (from_source/1), 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.

Mutare.Poison wants 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 mutant id that owns it.

(Coverage no longer lives here: 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 lives 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 lives away from the dispatcher;
  • 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.

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 — see exactly what they did before; the two parses yield identical ranges. (Sourceror is still the renderer; only this readback parse changed.)

Summary

Types

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.

Mutant ids whose generated code occupies line.

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

Types

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{regions: [region()]}

One file's manifest:

  • :regions — generated line ranges, for mapping a compile error back to a mutant.

Functions

from_source(metamutant_source)

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

Build a manifest from one file's rendered metamutant source.

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.

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)
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(metamutant_source, names)

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

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

The macro-expansion fallback's attribution (Mutare.Poison.macro_poison/2): when a mutation splices a selector case into an argument a macro rewrites at compile time, the macro raises during expansion and the compiler blames 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 over every reserved mutant — so it is correct under --line/--max-mutants, where Mutare.Schema's :sites are filtered but the metamutant still embeds (and can be poisoned by) every id. Returns %{fun_atom => MapSet.t()}, empty when nothing matched.