Mutation testing for hand-marked regions of source files
Project description
unimut
unimut (universal mutator) is a mutation testing tool that finds tests you are missing, built to scale across a project's whole lifecycle: a fast, precise gate on individual PRs (--diff), an exhaustive nightly audit of legacy code (--whole-file), and parallel execution across CI cores (--jobs) -- all from one tool, working on any language with a registered backend.
unimut tries mutations of your code and runs a --run command (typically "rebuild, then test") against each one:
- A mutant your tests catch (
--runfails) is killed -- the good outcome, and hidden by default. - A mutant your tests miss (
--runstill exits 0) survived -- a sign you're missing a test -- and gets printed.
$ unimut --file src/lj_ffrecord.c --run 'make -j$(nproc) && PATH="$PWD/src:$PATH" perl t/unpack.t'
src/lj_ffrecord.c:13
- if (tref_isnil(tri)) i = 1;
src/lj_ffrecord.c:27
- if (i > e) { rd->nres = 0; return; }
Survived: 2/35
- lines (removed code) print red; + lines (replacement code, for mutation kinds that have one) print green. unimut exits 0 if nothing survived, 1 otherwise (or on error) -- usable as a CI gate.
Installing
pip install unimut
or pip install -e . from a checkout for development. Requires Python 3.9+; the bundled C backend pulls in pycparser automatically.
Choosing what to mutate
| Mode | What it mutates |
|---|---|
| Default | Code wrapped in // unimut on / // unimut off markers |
--diff REF |
Whole file, filtered to lines that differ from REF |
--whole-file |
Whole file, exhaustively |
Default: marker-based
// unimut on
static void LJ_FASTCALL recff_unpack(jit_State *J, RecordFFData *rd)
{
...
}
// unimut off
Wrap a whole function, or just a run of statements as they'd appear inside one. A file can hold multiple on/off pairs; they cannot be nested.
--diff REF: a fast PR gate
unimut --file src/api.c --diff main --run 'make -j$(nproc) && make test'
Scans the whole file, but keeps only mutants on a line git diff REF...HEAD -- src/api.c reports as changed. A PR only has to prove the lines it touched are covered, not the whole file -- turning an hours-long whole-codebase run into a seconds-long diff-sized one. REF is anything git diff accepts (main, origin/main, a SHA); requires --file to be inside a git repo with REF resolvable.
--diff implies whole-file scanning, so the marker inversion below applies to it too.
--whole-file: a slow nightly audit
unimut --file src/api.c --whole-file --run 'make -j$(nproc) && make test'
Mutates every statement in the file -- the right mode for periodically auditing legacy code that never got markers. If // unimut on/off markers are still present, their meaning inverts: an off/on pair now marks a range to exclude, the same way tools like clang-format reuse on/off markers:
// unimut off
die("Out of memory"); // no test can reliably trigger this allocator failure
// unimut on
Excluded text still has to be part of a file that parses as C overall -- exclusion hides a range from mutation, not from parsing.
Running it
unimut --file <path> --run '<shell command>' [--diff REF | --whole-file] [--jobs N]
unimut never mutates your real files. It copies the whole repository (via git rev-parse --show-toplevel, or the current directory if that isn't a git checkout) into an isolated temp directory per job, and mutates and builds/tests that copy instead -- your working tree is untouched even if --run crashes or you hit Ctrl-C.
Options
| Flag | Meaning |
|---|---|
--file PATH |
source file to mutate (required) |
--run CMD |
shell command to build/test each mutant (required unless --print-mutant-counts) |
--lang {c} |
override language detection from --file's extension |
--diff REF |
PR-gate mode (see above) |
--whole-file |
nightly-audit mode (see above) |
--jobs N |
run N mutants at a time, each in its own isolated copy (default: 1) |
--print-mutant-counts |
print how many mutants would be tried, and exit |
--include-killed-mutants |
also print killed mutants, not just survivors |
A note on "ignored" compile failures
A mutation that fails to compile just makes --run fail like any other test failure, so it's counted and treated as killed -- there's no separate "ignored" bucket. That's intentional: a mutant that doesn't compile is indistinguishable from one a test caught, and should be, since either way nothing survived.
The C backend (mutate_c)
The C backend does one kind of mutation so far: remove a statement. It walks every { ... } block and generates one mutant per statement/declaration removed, recursing into nested blocks.
It's built on pycparser, which has no preprocessor and no idea what your project's types are called. Real code (like the LuaJIT recorder functions this was built for) uses unknown types (TRef, jit_State) and calling-convention macros (LJ_FASTCALL) that won't parse as-is, so mutate_c.py does a heuristic pre-pass first: strip comments (preserving line numbers), drop bare ALL_CAPS tokens in front of name(, and synthesize fake typedef int Name; stand-ins for identifiers that look like unknown types, on top of a small <stdint.h>-style preamble.
This recovers the statement structure of typical C -- enough for statement removal -- but it's not a general C frontend, and treats every unknown type as int-sized. A region that genuinely can't be parsed this way raises a clear error rather than silently doing the wrong thing.
Because pycparser's generator doesn't preserve formatting, applying a mutant regenerates the whole marked (or whole-file) region through pycparser's CGenerator; everything outside it is left byte-for-byte identical.
Run its own test suite (hardcoded C strings, no fixture files, compiled with whatever of cc/gcc/clang is on PATH) with:
python -m unittest unimut.mutate_c -v
Adding another language
Language backends are plain modules exposing:
EXTENSIONS: set[str] # e.g. {".c"}
def generate_mutants(file_path: str, source: str) -> list[Mutant]:
...
where Mutant has .file, .line, .original, .mutated (str | None), and an .apply(source: str) -> str method. To support --diff/--whole-file, also accept whole_file: bool = False and changed_lines: set[int] | None = None keyword arguments (see mutate_c.py); backends that don't will simply refuse those flags. Register the module in _LANGUAGES in unimut.py, keyed by the --lang value.
Planned
- More C mutation kinds beyond statement removal (operator flips, constant tweaks, condition negation) -- these will populate
Mutant.mutatedand print a+line. - Additional language backends.
Contributing
This project uses Black and Pyright. Run once to install a pre-commit hook that formats/checks staged files on every git commit:
pip install pre-commit && pre-commit install
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file unimut-0.1.1.tar.gz.
File metadata
- Download URL: unimut-0.1.1.tar.gz
- Upload date:
- Size: 23.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
08477879784723950f8fb8bd37a108e3b80c7160913e94e00b848f31f72c4098
|
|
| MD5 |
5eee97281fcb3ae77dfc97c43f1121f8
|
|
| BLAKE2b-256 |
bb5b6ca540324e7a6bf1edfc5d31c0f548fc0f5c437b057a89e196f273e42262
|
Provenance
The following attestation bundles were made for unimut-0.1.1.tar.gz:
Publisher:
build.yml on MyNameIsTrez/unimut
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
unimut-0.1.1.tar.gz -
Subject digest:
08477879784723950f8fb8bd37a108e3b80c7160913e94e00b848f31f72c4098 - Sigstore transparency entry: 2254700515
- Sigstore integration time:
-
Permalink:
MyNameIsTrez/unimut@8d8039e4fabda6ee46e97bc04aec1b94475cf21b -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/MyNameIsTrez
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
build.yml@8d8039e4fabda6ee46e97bc04aec1b94475cf21b -
Trigger Event:
push
-
Statement type:
File details
Details for the file unimut-0.1.1-py3-none-any.whl.
File metadata
- Download URL: unimut-0.1.1-py3-none-any.whl
- Upload date:
- Size: 20.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0a84e02eb6b5a9fd5aa7e94ecc46c069ba32f7c44bf2d9692f006b98231e2960
|
|
| MD5 |
1732706f560db7a345b4beb05cdaa568
|
|
| BLAKE2b-256 |
938ccf293e6c8272925a191fae9018a36e890ae22b32a0c63f6c1167ee8b0a74
|
Provenance
The following attestation bundles were made for unimut-0.1.1-py3-none-any.whl:
Publisher:
build.yml on MyNameIsTrez/unimut
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
unimut-0.1.1-py3-none-any.whl -
Subject digest:
0a84e02eb6b5a9fd5aa7e94ecc46c069ba32f7c44bf2d9692f006b98231e2960 - Sigstore transparency entry: 2254700577
- Sigstore integration time:
-
Permalink:
MyNameIsTrez/unimut@8d8039e4fabda6ee46e97bc04aec1b94475cf21b -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/MyNameIsTrez
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
build.yml@8d8039e4fabda6ee46e97bc04aec1b94475cf21b -
Trigger Event:
push
-
Statement type: