# Mutation testing with muex Mutation testing is a **sensor, not a gate**. It deliberately breaks production code one mutant at a time and re-runs the suite: a mutant *killed* by a test is evidence the test pins that behaviour; a mutant that *survives* is a question for a Lens session. Runs always pass `--fail-at 0`, so a survivor is reported and triaged, never a failing build. Nothing here is wired into `VERIFY_SENSORS`. `make check`, `make verify`, coverage floors and `.githooks/pre-push` are unchanged by this yak. ## How to run it Both apps declare two aliases in `mix.exs`; both read a plain-text scope file at run time, so the file stays the single source of truth. | Command | Scope | Blogex wall clock (2026-10-28) | |---------|-------|--------------------------------| | `mix mutate.fast` | `mutation_scope_fast.txt` (3 modules) | ~3 s | | `mix mutate` | `mutation_scope.txt` (8 modules) | ~4 s | The app declares the same aliases but both scope files are empty (see "Scope: app" below), so `mix mutate` there raises a clear Mix error instead of running a sensor over nothing. Extra arguments pass through to muex, so a one-off target is explicit: ```bash cd blogex mix mutate --files lib/blogex/seo.ex # one module, ad hoc mix mutate.fast --no-optimize # full mutant list, slower ``` The aliases pin `--fail-at 0` first, so an ad-hoc flag cannot turn the sensor into a gate. `mix mutate` does not enable `--cover`: muex runs each mutant's suite under `MIX_ENV=test`, and coverage would let a code-deleting mutant trip the project's coverage floor and abort the run. ## Scope: blogex | In scope (`mutation_scope.txt`) | Why | |---------------------------------|-----| | `lib/blogex/link_validator.ex` | Pure link parsing/validation; the yak's example module. | | `lib/blogex/seo.ex` | Pure sitemap/JSON-LD/OpenGraph string building. | | `lib/blogex/feed.ex` | Pure feed generation. | | `lib/blogex/post.ex` | Pure post parsing/visibility helpers. | | `lib/blogex/external_links.ex` | Pure HTML rewriting for external links. | | `lib/blogex/registry.ex` | Module-registry lookup; no I/O. | | `lib/blogex/not_found_error.ex` | Exception definition. | | `lib/blogex/link_error.ex` | Exception definition. | | Out of scope | Why | |--------------|-----| | `lib/blogex.ex` | Facade + moduledoc: delegation and docs, no logic to mutate. | | `lib/blogex/blog.ex` | A `defmacro __using__` that expands `NimblePublisher`; mutating macro bodies produces noise, not behaviour. | | `lib/blogex/router.ex` | Phoenix router DSL (`use`, `scope`, `get`); macro-generated. | | `lib/blogex/components.ex` | Phoenix component DSL (`use`, `attr`, `slot`, `~H`). | | `lib/blogex/layout.ex` | LiveView layout DSL. | `mutation_scope_fast.txt` is a subset (`link_validator`, `seo`, `post`) so the inner loop stays seconds long; a pin in `test/mutation_scope_test.exs` fails if it ever drifts outside the audited scope. Another pin fails if any scoped path stops existing, so a rename cannot silently leave the sensor reporting a score for a reduced set. ## Mutators The aliases pass an explicit curated set (muex 0.12 CLI names): ``` arithmetic, boolean, comparison, conditional, function_call, literal, return_value ``` Macro/`use`-based mutators are deliberately absent. The scoped modules are plain functions, and the macro-heavy modules are out of scope; macro mutations produce false-positive survivors rather than behavioural questions. The `arithmetic`, `boolean`, `comparison` and `conditional` families are the behavioural core; `function_call`, `literal` and `return_value` catch removed calls, changed constants and stubbed return values. ## Baseline (blogex, 2026-10-28) Command: `mix mutate` (default muex optimization, `--fail-at 0`). Result: **19 mutants, 12 killed, 0 survived, 0 invalid, 7 equivalent — 100%.** Wall clock: 3.5 s. The run that found the survivor below was 11 killed / 1 survived (91.67%) before its pin landed. | Module | Mutants | Killed | Survived | Equivalent | |--------|--------:|-------:|---------:|-----------:| | `link_validator.ex` | 7 | 4 | 0 | 3 | | `external_links.ex` | 6 | 5 | 0 | 1 | | `registry.ex` | 4 | 1 | 0 | 3 | | `post.ex` | 1 | 1 | 0 | 0 | | `seo.ex` | 1 | 1 | 0 | 0 | | `feed.ex` | 0 | 0 | 0 | 0 | | `not_found_error.ex` | 0 | 0 | 0 | 0 | | `link_error.ex` | 0 | 0 | 0 | 0 | muex's file analyser gives `not_found_error.ex` a complexity score of 0 and skips it under the default filter. The scope file still lists it: it is pure code and stays audited if it grows logic. `feed.ex` is analysed but the curated mutator set generates nothing for it. ### Survivors found and killed | Mutant | Resolution | |--------|------------| | `link_validator.ex:201` — `Boolean: and to or` in `validate_body/3`'s guard `when is_binary(body) and is_atom(blog)` | Genuine: the mutant let a string blog id through the guard. Pinned by two `FunctionClauseError` tests in `test/blogex/link_validator_test.exs`; `mix mutate.fast` then reports 0 survivors. | ## False-positive classes These are not gaps in behaviour; do not weaken a test to "kill" them. - **Commutative argument swaps.** `function_call` mutates `a and b` to `b and a`, `a == b` to `b == a`, and `a || b` to `b || a`. These compile to the same behaviour, and muex's trivial-compiler-equivalence check marks them `equivalent` (7 of the 19 baseline mutants). They are noise from the mutator, not survivors. - **Removed log lines.** A removed `Logger` call changes no return value; the suite cannot observe it. Such a mutant is documented here instead of adding a log-capture assertion that pins an implementation detail. - **`and`/`or` in guards is *not* automatically a false positive.** The baseline survivor above was a real guard-contract gap; only commutative rewrites are equivalent. - **Invalid mutants.** Mutated code that does not compile is counted `invalid`, not `survived`, and excluded from the score. ## Deeper runs muex's default `balanced` optimization reduced 1304 generated mutants to 12 tested ones (99.1%) on the blogex full scope. That is what keeps the sensor in seconds, but it is sampled, not exhaustive. `mix mutate --no-optimize` tests everything; on the three-module fast scope it took ~2 min 15 s and produced 434 mutants (329 killed, 19 survived, 83 invalid, 3 equivalent) before the guard pin. Use it when triaging a specific module, not in a loop. Re-baseline after a yak that changes the scoped modules (notably the Boundary work, which adds `use` lines inside them); do not compare scores across such a change. ## Scope: app **Deliberately empty; no app baseline was run.** The candidate pure modules are `Firehose.Accounts.User`, `Firehose.Accounts.Scope` and `Firehose.Accounts.UserToken`, but every test that exercises them lives in `test/firehose/accounts_test.exs`, which uses `Firehose.DataCase`, fixtures and `Repo`; the SQL sandbox needs PostgreSQL. The yak's Q45 decision was: use those modules only after confirming their tests need no database, otherwise leave the app scope empty. They need one, so the scope is empty. Consequences, all pinned by `app/test/mutation_scope_test.exs` and `app/test/mutation_config_test.exs`: - Both app scope files list nothing, so `mix mutate` / `mix mutate.fast` raise `mutation scope … is empty; refusing to run the mutation sensor over nothing`. - Ad-hoc `--files` arguments do not bypass the guard: the scope is validated before muex arguments are built. - No scope entry may ever begin with `lib/firehose_web` (controllers, LiveViews, components). There is no app mutation baseline to report. Do not add one until the scoped modules have tests that run without the database; change the scope file and these pins in the same commit.