The mutant schema for a whole project: every in-scope source transformed into its metamutant, with globally-unique report ids and stable per-file runtime ids.
This is what the runner compiles once and the report reads from.
Files that have no mutation sites, or that fail to parse, are left out of
:metamutants (their originals are used as-is) but never crash the build — a
single unparseable file should not sink the run. A failure after a clean
parse (transform or render) is a bug in this tool, not bad input, and is left
to crash: see render_one/3.
Two-phase build (from_files/4)
Report ids reserve contiguous ranges in file order — file i's :start_id
is file i-1's next_id. Emitted selectors instead use local integers under
the root-relative file namespace (Mutare.RuntimeId). An unrelated file's
candidate-count change therefore leaves this file's metamutant byte-identical,
provided its source, transform inputs, and effective emission selection stay
the same. A global cap can change that selection even with unchanged options.
The build determines report ranges and global selection before rendering in two passes, both run in throwaway worker processes so each file's heavy, short-lived ASTs (the emitted tree, Sourceror's render buffers) die with their worker instead of accumulating in the scan's long-lived heap, where they made every GC scan the growing live set and inflated a big file's transform several-fold (see NOTES "Scan is transform-bound"):
- Count (
count_files/4) —Mutare.Transform.count_report/2per file, in parallel. It runs the same analyze → plan → emit pipeline but skips the dominant final render, returning each file's mutant count and, for a line selection, the matching local ids (without rendering site diffs) — plus the facts the end-of-build diagnostics read (matched configuration entries, the comment-directive container, degradeduses), collected here because this is the one pass that parses every file: nothing later re-parses. The count is drift-proof: it comes from the same id-claiming path emission uses, so it equals the matching render'snext_id - start_idby construction. - Render (
render_files/3) — prefix-sum the counts so each sited file is given its globally-unique:start_idup front, thenMutare.Transform.transform_string/2each file (with that:start_idand the file's:runtime_namespace, and the run's report-space:skip_idsand statically selected:emit_ids) in parallel. Every candidate reserves its id and records its diagnostic site, but only selected candidates emit branches.render_one/3re-checks the count against the renderednext_idand fails loudly on any drift, since id stability across files depends on the two passes agreeing.
The two passes agree only because the pipeline they share — parse, use-expansion,
resolution, and every mutator — is a deterministic function of the source and opts;
it runs once per pass, so a nondeterministic custom mutator or extension expand_use/3
surfaces as that render_one/3 drift crash rather than a silent id overlap.
from_files/4 also dedups its input by relative path, so a file passed twice is
rendered once, under one id range — never two overlapping ones.
The slice is checked the same way. A --line / --max-mutants run narrows twice —
emission in render_jobs/2, the reported sites at the end of from_files/4 — so
from_files/4 compares the finished site ids against the ids it selected for emission
and raises on any difference: a reported site whose mutant never reached the metamutant
would run the suite unmutated and be scored a survivor.
Both passes preserve exact :start_id threading (the prefix sum reproduces the
old sequential thread) and the let-it-crash contract: a worker classifies an
unparseable source as a skipped file but re-raises any other exception, with its
original type and stacktrace, in the parent — so a tool bug still surfaces
faithfully across the process hop, not as an opaque Task exit.
Each sited file's :start_id is recorded under :start_ids — the origin of the
file's id range before :only_lines/:max_mutants narrow :sites. A
report-time re-render (render_opts/3) reads it from there, never from the
visible sites: under --line the smallest surviving id is not the file's first
mutant, and a re-render started from it would hand every id another site's code.
We store each file's rendered metamutant source (under :metamutants) and the
dispatch variable its generated code reads (under :dispatch_vars, beside
:start_ids; Mutare.Transform.Result.dispatch_var) but not a precomputed
Mutare.Manifest: the manifest (generated line ranges, for
mapping a compile error back to a mutant) is read only on a failed compile,
so Mutare.Poison builds it lazily from the metamutant for the offending
file(s). Building it eagerly here was the scan's dominant cost — a full
Sourceror.parse_string! of a ~40k-line metamutant takes minutes — and was
thrown away on every healthy run.
Summary
Types
A module-level use the scan could not expand in-process, with the root-relative file it
sits in (Mutare.Transform.Uses.degraded_use/0 plus :file). mix mutare --check prints
these: a :call_routes :raw keyed on what such a use injects would silently never fire.
Functions
Build a schema by discovering files under root.
Total number of mutants in the schema.
Build a schema from an explicit list of files (paths recorded relative to root).
Rebuild a schema from the same file list it was built with.
Transform options for re-rendering one file's sites.
Types
@type degraded_use() :: %{ file: String.t(), module: module(), line: pos_integer() | nil, reason: atom() }
A module-level use the scan could not expand in-process, with the root-relative file it
sits in (Mutare.Transform.Uses.degraded_use/0 plus :file). mix mutare --check prints
these: a :call_routes :raw keyed on what such a use injects would silently never fire.
@type t() :: %Mutare.Schema{ degraded_uses: [degraded_use()], dispatch_vars: %{optional(String.t()) => atom()}, files: [String.t()], ineffective_argument_marks: [Mutare.Mutator.mark_declaration()], ineffective_call_routes: [Mutare.CallRouting.Spec.t()], ineffective_ignores: [ {String.t(), Mutare.Ignore.Directive.t(), pos_integer() | nil} ], ineffective_skip_lifting: [Mutare.Lifting.skip_entry()], metamutants: %{optional(String.t()) => String.t()}, sites: [Mutare.Site.t()], skipped: [{String.t(), term()}], sources: %{optional(String.t()) => String.t()}, start_ids: %{optional(String.t()) => pos_integer()}, unknown_directives: [{String.t(), pos_integer(), String.t()}] }
Functions
@spec build(Path.t(), Mutare.Run.Context.t() | Mutare.Options.t() | keyword()) :: t()
Build a schema by discovering files under root.
opts may be a Mutare.Run.Context, a Mutare.Options struct, or a keyword
list. Discovery uses:
:paths— directories to scan recursively, or individual.exfiles.:exclude— wildcard patterns to drop.:only_files— an explicit root-relative file set, such as--since.:only_lines—file:linefilters, such as--line; discovery is narrowed to the named files, thenfrom_files/4filters the sites.:mutators— forwarded toMutare.Transform.:max_mutants— caps the final schema to the first N sites; seefrom_files/4.
@spec count(t()) :: non_neg_integer()
Total number of mutants in the schema.
@spec from_files( [Path.t()], Path.t(), Mutare.Run.Context.t() | Mutare.Options.t() | keyword(), MapSet.t() ) :: t()
Build a schema from an explicit list of files (paths recorded relative to root).
Duplicate file entries are collapsed by root-relative path so each source file has one stable id range.
skip_ids is poison-recovery state: ids to leave out of the emitted
metamutant while still advancing the id counter. It is passed separately from
Options because it is run state, not user configuration.
:only_lines filters the finished sites to the requested file:line pairs.
:max_mutants then caps those sites in source order. Both filters are applied
here so poison recovery can rebuild from the same inputs and still return the
same visible slice. Every candidate reserves its id, including skipped ids,
but only selected candidates that are neither ignored nor poisoned emit code. Poisoned and ignored
sites still occupy their places inside the cap, as they do in the report.
Files with no emitted mutants retain their exact original source. Changing selection
can therefore change the metamutant and invalidate a retained sandbox's build.
@spec rebuild( t(), Path.t(), Mutare.Run.Context.t() | Mutare.Options.t() | keyword(), MapSet.t() ) :: t()
Rebuild a schema from the same file list it was built with.
This is the poison-recovery entry point. It does not rediscover files, because
rediscovery could lose restrictions from from_files/4, :only_files,
:exclude, or :only_lines. Reusing the recorded file list keeps the run's
scope and mutant ids stable while adding the new skip_ids.
Pass the same options used for the original schema so mutator and extension configuration stays unchanged.
@spec render_opts(Mutare.Options.t(), String.t(), pos_integer()) :: keyword()
Transform options for re-rendering one file's sites.
The returned keyword list is transform_opts/1 plus :file, :start_id, and
the file's :runtime_namespace.
Mutare.Runner.Hydrate uses it when a scan deferred site-code rendering and a
report later needs the original/mutated code for one displayed site.
The :start_id must be the schema's recorded :start_ids entry for file —
the pre-filter origin of its id range — so the re-rendered sites line up with
the original scan even when :only_lines/:max_mutants narrowed :sites. :skip_ids and
:render_site_code are intentionally left to Mutare.Transform.render_sites/2;
neither changes the id-to-code mapping.