Materialise a schema as a runnable copy of the target project.
We copy the project to a dedicated directory (excluding build output), write
each metamutant over its original, and inject a tiny bootstrap into
test/test_helper.exs that combines MUTARE_MUTANT_NAMESPACE (the active
mutant's file) and MUTARE_ACTIVE_MUTANT (its id within that file) into one
:persistent_term entry before the suite starts. A second injection prefixes
config/config.exs with the owner-death watcher
(Mutare.Sandbox.Command.Invocation.owner_watch_ast/0),
so every sandbox mix — including the one-time compile, which runs before any
test bootstrap — halts itself if the Mutare process that spawned it dies,
instead of surviving as an orphan. Everything injected is plain Erlang/Elixir
with no dependency on Mutare, so the sandbox needs nothing added to its deps.
Sandbox mix.exs files also wrap project/0 to disable signature inference
in the effective compiler options (Mutare.Sandbox.CompilerOptions), including
every umbrella child. Mutare.Sandbox.ProjectEvaluation marks escaping project
failures so the decoder retains startup-kill attribution even without project
stack frames. The target's own project files remain untouched.
One copy serves every concurrent mutant run: they share this sandbox and its
_build, and Mix's build lock serialises only their --no-compile boot check —
a small, fixed fraction of a run (measured in NOTES.md,
"Per-worker MIX_BUILD_PATH vs the shared sandbox build") — so there is no
per-worker isolation to configure.
Two materialisation modes, chosen by :keep_sandbox:
- kept (default,
keep_sandbox: true) — the sandbox (and its compiled_build) is preserved between runs and re-materialised in place byMutare.Sandbox.Mirror: a file is rewritten only when its desired content differs (unchanged files keep their mtime, so mix's incremental compiler reuses_build), and files no longer mirrored, generated, or explicitly managed are pruned. The mirror carries each source's permission mode and recreates its symlinks (never following them), matching what the fresh copy preserves. Without an explicit:sandboxit lives at a stable per-project temp dir; CI pins:sandboxat a cached directory instead. SeeNOTES.mdfor the cache pattern. - fresh (
keep_sandbox: false) — a throwaway dir is wiped and re-copied every run, so the metamutant recompiles cold, and the runner removes it afterwards. Always correct, no caching — the reset for a kept sandbox that has gone bad.
This module materialises the workspace. Sandbox mix invocations and
per-mutant timeouts are implemented in
Mutare.Sandbox.Command.Invocation.
Summary
Types
Results of prepare/3 for --verbose output, which
Mutare.Runner.Compile relays on the :on_phase hook: the app-build seed's outcome
(t:Mutare.Sandbox.Seed.summary/0), and each mix.exs whose type-signature-inference
override was not applied, paired with the reason, in path order. Such a project compiles with
inference on, which can stretch the one compile from seconds to hours, so a long compile
should not go unexplained.
Functions
The bootstrap snippet prepended to the sandbox's test helper.
Prepare a sandbox for schema taken from root. Returns the sandbox path and a
materialized/0 summary of what happened.
Re-render schema's metamutants into an already-prepared sandbox, in place.
Types
@type materialized() :: %{ seed: Mutare.Sandbox.Seed.summary(), declined: [{Path.t(), String.t()}] }
Results of prepare/3 for --verbose output, which
Mutare.Runner.Compile relays on the :on_phase hook: the app-build seed's outcome
(t:Mutare.Sandbox.Seed.summary/0), and each mix.exs whose type-signature-inference
override was not applied, paired with the reason, in path order. Such a project compiles with
inference on, which can stretch the one compile from seconds to hours, so a long compile
should not go unexplained.
Functions
@spec bootstrap() :: String.t()
The bootstrap snippet prepended to the sandbox's test helper.
@spec prepare( Path.t(), Mutare.Schema.t(), Mutare.Run.Context.t() | Mutare.Options.t() | keyword() ) :: {Path.t(), materialized()}
Prepare a sandbox for schema taken from root. Returns the sandbox path and a
materialized/0 summary of what happened.
opts may be a Mutare.Run.Context, a Mutare.Options struct, or a keyword
list. :sandbox selects the target directory; without it Mutare uses a stable
per-project temp directory (kept mode, the default) or a fresh one (keep_sandbox: false). The context's project scope controls which umbrella apps are
materialized; its hooks are not consulted — the runner reports the summary.
The sandbox must be separate from the project tree: it may not be the project
root, contain the project, or live inside it. This check is done here because it
depends on root.
Mutare will only use a target path that is absent, empty, or already marked as a
Mutare-owned sandbox. Any other existing path is refused without modification.
An owned directory is reused only for an explicit :sandbox path or when
:keep_sandbox is enabled. If a generated fresh-path sandbox already exists,
Mutare rejects it as a stale leftover.
@spec rematerialize(Path.t(), Mutare.Schema.t()) :: Path.t()
Re-render schema's metamutants into an already-prepared sandbox, in place.
This is the poison-recovery path. After a failed compile removes the implicated mutant ids and rebuilds the schema, the project copy, bootstrap, coverage helper, and seeded builds are still valid. Only the metamutant source files may have changed.
The rewrite is byte-aware, so unchanged files keep their timestamps and Mix recompiles as little as possible. The same sandbox path is returned.