Add a multi-language mutation tester that writes .metrics/mutate for uml-viewer.

This commit is contained in:
Robert C. Martin
2026-09-30 10:40:54 -05:00
commit bf42bd22e7
24 changed files with 3201 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
.metrics/
target/
*.egg-info/
dist/
build/
+116
View File
@@ -0,0 +1,116 @@
# mutator
Mutation testing for Clojure, Java, Go, TypeScript, Rust, and Python. One run detects the language of each source file, applies that language's mutation rules, and writes the snapshot [uml-viewer](https://github.com/unclebob/uml-viewer) already reads.
Clojure follows [clj-mutate](https://github.com/unclebob/clj-mutate). Go follows [mutate4go](https://github.com/unclebob/mutate4go). Java follows [mutate4java](https://github.com/unclebob/mutate4java). TypeScript, Rust, and Python use the same decisions as Java, spelled in that language. Function names and namespaces come from [crapper](https://github.com/unclebob/crapper), which is what uml-viewer joins to `.metrics/crap.edn`.
A mutant is killed when the tests fail or time out. It survives when the tests still pass. A site on a line the coverage report does not hit is uncovered and is not run. The score uml-viewer paints is killed / (killed + survived).
## Run
```bash
./mutator
```
The first run creates `.venv` and installs the tool. crapper has to be checked out next to this directory (`../crapper`), because that is where the namespace and function names come from. From a project root the tool walks the tree, skips `test`, `spec`, `vendor`, `node_modules`, and `target`, and writes `.metrics/mutate/<namespace>.edn`.
The first run of a tree executes every covered mutant. Start with the file you are working on.
```bash
./mutator src/demo/core.clj # one file, or a directory
./mutator --scan src/demo # list sites, do not run tests
./mutator --changed # git additions and edits
./mutator --mutate-all # rerun killed mutants too
./mutator --no-coverage # do not skip uncovered lines
./mutator --use-existing-coverage # read reports already on disk
./mutator --test-command "pytest -q tests/test_demo.py"
```
Once a snapshot exists, the next run is differential. It reruns survivors and every site in a function whose text changed. Killed mutants in an unchanged function stay killed. `--since-last-run` is that same selection. `--mutate-all` ignores it.
Exit `0` when every executed mutant was killed. Exit `2` when the baseline tests fail. Exit `3` when a mutant survives. A failed baseline does not rewrite the snapshot.
Mutants run one at a time in the project tree. The original file is restored after each one. An interrupted run leaves a copy under `target/mutator-backup/`, and the next run puts it back before reading source.
## Snapshot
uml-viewer loads every `*.edn` file under `.metrics/mutate`. Each file is one namespace. A Java file with an inner class, or a Go file with a function and a method, therefore writes more than one file. Two Go files in the same package share one snapshot, and rerunning one of them keeps the other's forms.
```clojure
{:version 1
:source "src/demo/board.py"
:namespace "demo.board.Board"
:outcomes {"[\"src/demo/board.py\",\"demo.board.Board\",\"defn/place\",10,11,\">\",\">=\"]" :survived}
:forms [{:id "defn/place"
:kind "defn"
:file "src/demo/board.py"
:line 2
:end-line 4
:hash "…"
:killed 1
:survived 1
:uncovered 0
:sites 2}
{:id "defn-/_hide"
:kind "defn-"
:killed 0
:survived 0
:uncovered 1
:sites 1}]}
```
`:namespace` is the class key crapper uses. The form id is what the viewer splits:
| Form id | Operation | Private |
| --- | --- | --- |
| `defn/place` | `place` | no |
| `defn-/hide` | `hide` | yes |
`:sites` is every mutation in the function, including uncovered ones. The viewer prints `---no mutation sites---` when that count is zero.
| Language | `:namespace` | `:name` |
| --- | --- | --- |
| Clojure | the `ns` | the `defn` / `defn-` name |
| Java | `package.Class`, or `package.Outer.Inner` | the method name |
| Go | the package import path, or `import/path.Receiver` | the function or method name |
| TypeScript | the dotted module path, or `module.Class` | the function or method name |
| Rust | `crate::module`, or `crate::module::Type` | the function or method name |
| Python | the dotted module path, or `module.Class` | the function or method name |
`defn-` is Clojure's private form. Elsewhere a private method, an unexported Go function, a Python name that starts with `_`, a Rust function that is not `pub`, or a TypeScript `private` / `#` name uses `defn-/`.
## What each language mutates
Tokens inside strings, comments, and quoted Clojure forms are left alone. A replacement that does not compile fails the test run and counts as killed.
| Language | Mutations |
| --- | --- |
| Clojure | clj-mutate's head symbols: `+`/`-`, `*` to `/`, `inc`/`dec`, comparisons, `=`/`not=`, `if`/`if-not`, `when`/`when-not`, `and`/`or`, and the seq, predicate, and coercion pairs. `true`/`false` and `0`/`1` anywhere. |
| Java | `true`/`false`, `==`/`!=`, `<`/`<=`, `>`/`>=`, `+`/`-`, `*`/`/`, `&&`/`||`, delete `!` and unary `-`, `0`/`1`. Constructors are not entries, matching crapper. |
| Go | mutate4go: `+`/`-`, `*` to `/` only, comparisons, `==`/`!=`, `true`/`false`, `&&`/`||`, `0`/`1`. |
| TypeScript | the Java set, plus `===`/`!==`. |
| Rust | the Java set (`&&`/`||`, `!`, unary `-`). |
| Python | the Java set, spelled `and`/`or`, `not`, and `True`/`False`. |
## Tests and coverage
The baseline command has to pass before any mutant runs. A mutant's timeout is ten times that baseline, and at least two seconds. `--timeout-factor` changes the multiple. `--test-command` replaces the default. Defaults:
| Language | Command | Where |
| --- | --- | --- |
| Clojure | `clj -M:spec --tag ~no-mutate` when Speclj is on the classpath, otherwise `clj -M:test` or `bb spec` | nearest `deps.edn` or `bb.edn` |
| Java | `mvn -q test -DexcludeTags=no-mutate` | nearest `pom.xml` |
| Go | `go test -count=1` of the file's package | nearest `go.mod` |
| TypeScript | `npm test` | nearest `package.json` |
| Rust | `cargo test` | nearest `Cargo.toml` |
| Python | `python -m pytest`, or `unittest discover` | nearest project file |
Coverage is generated with crapper's commands unless `--use-existing-coverage` or `--no-coverage` is set. A file missing from the report is treated as uncovered, and the run says so.
## Development
```bash
python3 -m venv .venv
.venv/bin/pip install -e '.[dev]'
.venv/bin/pytest
```
Executable
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Run the mutation tool, creating a local virtualenv on first use.
ROOT=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
PY="$ROOT/.venv/bin/python"
if [ ! -x "$PY" ]; then
python3 -m venv "$ROOT/.venv"
"$ROOT/.venv/bin/pip" install -e "$ROOT"
fi
exec "$PY" -m mutator "$@"
+26
View File
@@ -0,0 +1,26 @@
[project]
name = "mutator"
version = "0.1.0"
description = "Mutation testing for Clojure, Java, Go, TypeScript, Rust, and Python, in the snapshot uml-viewer reads."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"tree-sitter>=0.24",
"tree-sitter-language-pack>=0.7",
]
[project.scripts]
mutator = "mutator.cli:main"
[project.optional-dependencies]
dev = ["pytest>=8"]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+3
View File
@@ -0,0 +1,3 @@
"""Mutation testing for the languages crapper scores."""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from mutator.cli import main
if __name__ == "__main__":
main()
+462
View File
@@ -0,0 +1,462 @@
"""Command line for the multi-language mutation tool."""
from __future__ import annotations
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from mutator.crapper_link import ensure_crapper
from mutator.coverage import covered_lines
from mutator.engine import mutate_file, restore_backups, scan_file
from mutator.report import format_results, format_site_log
from mutator.runner import CommandRunner
HELP = """\
Usage: mutator [options] [path-or-filter ...]
Discover mutation sites in Clojure, Java, Go, TypeScript, Rust, and Python,
run the project's tests against each one, and write `.metrics/mutate` for
uml-viewer. Namespaces and function names are the ones crapper writes into
`.metrics/crap.edn`.
A form id is `defn/<name>` or, for a private operation, `defn-/<name>`.
uml-viewer joins that name to the operation and joins `:namespace` to the
class.
Options:
-h, --help Print this help and exit.
--root <path> Project root. Metrics are written here.
Default: the current directory.
-s, --source-root <path> Walk this tree instead of the project root.
May be repeated.
--changed Mutate added and modified source files from
git status.
--scan List mutation sites. Do not run tests or
write metrics.
--mutate-all Run every covered site, including ones the
last snapshot already killed.
--since-last-run Run survivors and sites in new or rewritten
functions. This is the default when a snapshot
exists.
--lines <n,n,...> Run only mutations on these source lines.
--no-coverage Treat every site as covered.
--use-existing-coverage Read coverage already on disk. Same as
--reuse-coverage.
--reuse-coverage Read coverage already on disk.
--coverage-command <cmd> Run this command instead of the per-language
coverage tools, then read the reports it wrote.
--test-command <cmd> Run this command for the baseline and every
mutant. The working directory is the project
root.
--timeout-factor <number> Mutant timeout, as a multiple of the baseline
duration. Default: 10. The timeout is at least
2 seconds.
--mutation-warning <number> Warn when a file selects more covered sites
than this. Default: 50.
--verbose Print each test command and each mutant.
Arguments:
path File or directory to mutate. Test paths are skipped.
filter When the argument is not a path, only source files whose
path contains this text are mutated.
With no paths, source files under the project root are mutated. Directories
named test, tests, spec, specs, vendor, node_modules, and target are skipped.
The default, once a snapshot exists, reruns survivors and sites in functions
whose text changed. Killed mutants in unchanged functions are kept.
Exit codes:
0 every executed mutant was killed, or there was nothing to run
1 usage error
2 baseline tests failed
3 at least one mutant survived
Coverage, when it is produced, uses the same commands as crapper:
Clojure clj -M:cov --lcov
Java Maven JaCoCo
Go go test ./... -coverprofile=...
TypeScript npm run coverage, Vitest, or c8
Rust cargo llvm-cov or cargo tarpaulin
Python coverage.py LCOV
A site on a line the report does not mark as hit is uncovered and is not run.
"""
@dataclass
class Options:
action: str
message: str = ""
exit_code: int = 0
project_root: Path = field(default_factory=lambda: Path("."))
source_roots: list[str] = field(default_factory=list)
positionals: list[str] = field(default_factory=list)
scan: bool = False
mutate_all: bool = False
since_last_run: bool = False
lines: set[int] | None = None
no_coverage: bool = False
use_existing_coverage: bool = False
coverage_command: str | None = None
test_command: str | None = None
timeout_factor: float = 10.0
mutation_warning: int = 50
changed: bool = False
verbose: bool = False
def _take(args: list[str], index: int, option: str) -> str:
if index + 1 >= len(args) or not args[index + 1] or args[index + 1].startswith("-"):
raise ValueError(f"{option} requires a value")
return args[index + 1]
def _lines(value: str) -> set[int]:
found = set()
for piece in value.split(","):
piece = piece.strip()
if not piece:
continue
number = int(piece)
if number < 1:
raise ValueError("--lines requires positive line numbers")
found.add(number)
if not found:
raise ValueError("--lines requires a line number")
return found
def parse_args(argv: list[str] | None = None) -> Options:
args = list(sys.argv[1:] if argv is None else argv)
if any(arg in {"-h", "--help"} for arg in args):
return Options(action="help", message=HELP, exit_code=0)
options = Options(action="mutate")
index = 0
try:
while index < len(args):
arg = args[index]
if arg in {"-s", "--source-root"}:
options.source_roots.append(_take(args, index, arg))
index += 2
continue
if arg == "--root":
options.project_root = Path(_take(args, index, arg))
index += 2
continue
if arg == "--coverage-command":
options.coverage_command = _take(args, index, arg)
index += 2
continue
if arg == "--test-command":
options.test_command = _take(args, index, arg)
index += 2
continue
if arg == "--timeout-factor":
options.timeout_factor = float(_take(args, index, arg))
if options.timeout_factor <= 0:
raise ValueError("--timeout-factor requires a positive number")
index += 2
continue
if arg == "--mutation-warning":
options.mutation_warning = int(_take(args, index, arg))
index += 2
continue
if arg == "--lines":
options.lines = _lines(_take(args, index, arg))
index += 2
continue
if arg == "--no-coverage":
options.no_coverage = True
index += 1
continue
if arg in {"--use-existing-coverage", "--reuse-coverage"}:
options.use_existing_coverage = True
index += 1
continue
if arg == "--changed":
options.changed = True
index += 1
continue
if arg == "--scan":
options.scan = True
index += 1
continue
if arg == "--mutate-all":
options.mutate_all = True
index += 1
continue
if arg == "--since-last-run":
options.since_last_run = True
index += 1
continue
if arg == "--verbose":
options.verbose = True
index += 1
continue
if arg.startswith("-"):
raise ValueError(f"Unknown option: {arg}")
options.positionals.append(arg)
index += 1
except ValueError as exc:
return Options(action="help", message=f"{exc}\n\n{HELP}", exit_code=1)
if options.mutate_all and options.since_last_run:
return Options(
action="help",
message=f"--mutate-all cannot be combined with --since-last-run\n\n{HELP}",
exit_code=1,
)
if options.no_coverage and options.coverage_command:
return Options(
action="help",
message=f"--no-coverage cannot be combined with --coverage-command\n\n{HELP}",
exit_code=1,
)
if options.scan and options.mutate_all:
return Options(
action="help",
message=f"--scan cannot be combined with --mutate-all\n\n{HELP}",
exit_code=1,
)
return options
def _changed_files(root: Path) -> list[Path]:
result = subprocess.run(
["git", "status", "--porcelain"],
cwd=root,
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
print(result.stderr.strip() or "git status failed", file=sys.stderr)
return []
found: list[Path] = []
for line in result.stdout.splitlines():
if len(line) < 4:
continue
path_text = line[3:].strip()
if " -> " in path_text:
path_text = path_text.split(" -> ", 1)[1]
path_text = path_text.strip('"')
found.append((root / path_text).resolve())
return found
def _positionals(root: Path, args: list[str]) -> tuple[list[Path], list[str]]:
existing: list[Path] = []
filters: list[str] = []
for arg in args:
candidate = Path(arg)
if not candidate.is_absolute():
candidate = root / arg
if candidate.exists():
existing.append(candidate.resolve())
else:
filters.append(arg)
return existing, filters
def _changed_sources(root: Path, crapper) -> list[Path]:
files = []
for path in _changed_files(root):
if crapper.discover.language_of(path) is None:
continue
if crapper.discover.is_test_file(path):
continue
files.append(path)
return files
def _is_source(path: Path, crapper) -> bool:
if crapper.discover.language_of(path) is None:
return False
return not crapper.discover.is_test_file(path)
def _explicit_sources(existing: list[Path], crapper) -> list[Path]:
files = []
for path in existing:
if path.is_dir():
files.extend(crapper.discover.iter_source_files([path]))
continue
if _is_source(path, crapper):
files.append(path)
continue
if crapper.discover.is_test_file(path):
print(f"Skipping test file {path}", file=sys.stderr)
return files
def _choose_files(options: Options, root: Path, existing: list[Path], crapper) -> list[Path]:
if options.changed:
return _changed_sources(root, crapper)
if options.source_roots:
return crapper.discover.iter_source_files([(root / path).resolve() for path in options.source_roots])
if existing:
return _explicit_sources(existing, crapper)
return crapper.discover.iter_source_files([root])
def _matching(files: list[Path], filters: list[str]) -> list[Path]:
if not filters:
return files
return [path for path in files if any(item in path.as_posix() for item in filters)]
def _inside_root(files: list[Path], root: Path) -> list[Path]:
inside = []
for path in files:
try:
path.resolve().relative_to(root)
except ValueError:
print(f"Skipping {path}; it is outside {root}", file=sys.stderr)
continue
inside.append(path.resolve())
return inside
def select_files(options: Options) -> list[Path]:
crapper = ensure_crapper()
root = options.project_root.resolve()
existing, filters = _positionals(root, options.positionals)
files = _choose_files(options, root, existing, crapper)
return sorted(set(_inside_root(_matching(files, filters), root)), key=lambda item: item.as_posix())
def _prepare_coverage(options: Options, root: Path, files: list[Path]) -> None:
if options.no_coverage or options.use_existing_coverage:
return
if options.scan:
return
crapper = ensure_crapper()
crapper.runners.run_coverage(root, files, options.coverage_command)
def _coverage_for(options: Options, root: Path, path: Path) -> set[int] | None:
if options.no_coverage:
return None
crapper = ensure_crapper()
language = crapper.discover.language_of(path)
if language is None:
return None
return covered_lines(root, path, language)
def _print_help(options: Options) -> int:
stream = sys.stdout if options.exit_code == 0 else sys.stderr
ending = "" if options.message.endswith("\n") else "\n"
print(options.message, file=stream, end=ending)
return options.exit_code
def _require_crapper() -> str | None:
try:
ensure_crapper()
except ImportError as exc:
return str(exc)
return None
def _restore(root: Path) -> None:
for path in restore_backups(root):
print(f"Restored {path} from an interrupted mutation.", file=sys.stderr)
def _scan(options: Options, root: Path, files: list[Path]) -> int:
for path in files:
print(
scan_file(
path,
root,
covered_lines=_coverage_for(options, root, path),
ignore_coverage=options.no_coverage,
lines=options.lines,
),
end="",
)
return 0
def _record(result, forms: list, written: list[str]) -> str:
if result.skipped:
print(f"Skipping {result.path}: {result.skipped}", file=sys.stderr)
return "skip"
if result.baseline_failed:
print(result.baseline_message, file=sys.stderr)
return "baseline"
print(format_site_log(result.sites, result.statuses), end="")
forms.extend(result.forms)
written.extend(result.written)
if any(form.survived for form in result.forms):
return "survived"
return "ok"
def _finish(baseline_failed: bool, survived: bool) -> int:
if baseline_failed:
return 2
if survived:
return 3
return 0
def _mutate_files(options: Options, root: Path, files: list[Path]) -> int:
runner = CommandRunner(verbose=options.verbose)
baselines: dict[tuple[str, str], tuple[bool, float, str]] = {}
forms = []
written: list[str] = []
baseline_failed = False
survived = False
for path in files:
result = mutate_file(
path,
root,
runner=runner,
covered_lines=_coverage_for(options, root, path),
ignore_coverage=options.no_coverage,
mutate_all=options.mutate_all,
lines=options.lines,
test_command=options.test_command,
timeout_factor=options.timeout_factor,
mutation_warning=options.mutation_warning,
baselines=baselines,
)
outcome = _record(result, forms, written)
if outcome == "baseline":
baseline_failed = True
elif outcome == "survived":
survived = True
if forms:
print(format_results(forms), end="")
for path in written:
print(f"Wrote {path}", file=sys.stderr)
return _finish(baseline_failed, survived)
def run(argv: list[str] | None = None) -> int:
options = parse_args(argv)
if options.action == "help":
return _print_help(options)
missing = _require_crapper()
if missing:
print(missing, file=sys.stderr)
return 2
root = options.project_root.resolve()
files = select_files(options)
if not files:
print("No source files to mutate.")
return 0
if not options.scan:
_restore(root)
_prepare_coverage(options, root, files)
if options.scan:
return _scan(options, root, files)
return _mutate_files(options, root, files)
def main(argv: list[str] | None = None) -> None:
sys.exit(run(argv))
+161
View File
@@ -0,0 +1,161 @@
"""Decide which lines the tests already exercise.
Java uses JaCoCo source lines. Go uses statement profiles. Clojure prefers
Cloverage's per-line form counts, then LCOV. The other languages use LCOV.
A file that does not appear in a report has no coverage data.
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
from pathlib import Path
from mutator.crapper_link import ensure_crapper
def _candidates(normalize, source_path: str) -> list[str]:
absolute = normalize(str(Path(source_path).resolve())) if source_path else ""
candidates = []
for value in (normalize(source_path), absolute):
if value and value not in candidates:
candidates.append(value)
return candidates
def _exact(normalized: dict, candidates: list[str]):
for candidate in candidates:
if candidate in normalized:
return normalized[candidate]
return None
def _suffix_hit(key: str, candidates: list[str], suffix_match) -> bool:
for candidate in candidates:
if suffix_match(key, candidate) or suffix_match(candidate, key):
return True
return False
def _by_suffix(normalized: dict, candidates: list[str], suffix_match):
for key, value in normalized.items():
if _suffix_hit(key, candidates, suffix_match):
return value
return None
def _lookup(index: dict, source_path: str):
if not index:
return None
crapper = ensure_crapper()
normalize = crapper.coverage.normalize_path
normalized = {normalize(key): value for key, value in index.items()}
candidates = _candidates(normalize, source_path)
found = _exact(normalized, candidates)
if found is not None:
return found
return _by_suffix(normalized, candidates, crapper.coverage.suffix_match)
def _covered_from_pairs(lines: dict[int, tuple[int, int]] | None) -> set[int] | None:
if lines is None:
return None
return {number for number, (covered, _total) in lines.items() if covered > 0}
def _jacoco_paths(root: Path) -> list[Path]:
paths = [root / "target" / "site" / "jacoco" / "jacoco.xml"]
paths.extend(root.glob("*/target/site/jacoco/jacoco.xml"))
paths.extend(root.glob("*/*/target/site/jacoco/jacoco.xml"))
return paths
def _line_number(line) -> int | None:
try:
number = int(line.get("nr") or "0")
instructions = int(line.get("ci") or "0")
branches = int(line.get("cb") or "0")
except ValueError:
return None
if instructions > 0 or branches > 0:
return number
return None
def _source_lines(source) -> set[int]:
covered = set()
for line in source.findall("line"):
number = _line_number(line)
if number is not None:
covered.add(number)
return covered
def _package_lines(xml_root) -> dict[str, set[int]]:
found: dict[str, set[int]] = {}
for package in xml_root.iter("package"):
package_name = (package.get("name") or "").strip("/")
for source in package.findall("sourcefile"):
filename = source.get("name") or ""
key = f"{package_name}/{filename}" if package_name else filename
found[key] = _source_lines(source)
return found
def _read_jacoco(path: Path, crapper):
text = path.read_text(encoding="utf-8", errors="replace")
cleaned = crapper.coverage._DOCTYPE.sub("", text)
try:
return ET.fromstring(cleaned)
except ET.ParseError:
return None
def _jacoco_index(root: Path) -> dict[str, set[int]]:
crapper = ensure_crapper()
found: dict[str, set[int]] = {}
seen: set[Path] = set()
for path in _jacoco_paths(root):
if not path.is_file():
continue
resolved = path.resolve()
if resolved in seen:
continue
seen.add(resolved)
xml_root = _read_jacoco(path, crapper)
if xml_root is None:
continue
found.update(_package_lines(xml_root))
return found
def _go_lines(profile, source_path: str) -> set[int] | None:
if not profile:
return None
crapper = ensure_crapper()
segments = crapper.coverage._profile_segments(profile, source_path)
if segments is None:
return None
covered: set[int] = set()
for start, end, _statements, hits in segments:
if hits <= 0:
continue
for line in range(start, end + 1):
covered.add(line)
return covered
def covered_lines(root: Path, source_path: Path, language: str) -> set[int] | None:
"""Lines with a hit, or None when this file is absent from coverage."""
crapper = ensure_crapper()
path = source_path.resolve().as_posix()
if language == "java":
return _lookup(_jacoco_index(root), path)
bundle = crapper.coverage.load_bundle(root)
if language == "go":
return _go_lines(bundle.go_profile, path)
if language == "clojure":
html = _covered_from_pairs(_lookup(bundle.form_html, path))
if html is not None:
return html
return _covered_from_pairs(_lookup(bundle.lcov, path))
+36
View File
@@ -0,0 +1,36 @@
"""Import crapper from the environment or the sibling checkout.
Function names and namespaces come from crapper so `.metrics/mutate` joins
the same operations uml-viewer joins to `.metrics/crap.edn`.
"""
from __future__ import annotations
import sys
from pathlib import Path
def _sibling_src() -> Path:
project = Path(__file__).resolve().parents[2]
return project.parent / "crapper" / "src"
def ensure_crapper():
try:
import crapper
except ImportError:
sibling = _sibling_src()
if not (sibling / "crapper").is_dir():
raise ImportError(
"crapper is not installed and was not found at "
f"{sibling}. Clone github.com/unclebob/crapper next to mutator."
) from None
sys.path.insert(0, str(sibling))
import crapper
import crapper.coverage
import crapper.discover
import crapper.languages
import crapper.languages.treesitter
import crapper.runners
return crapper
+206
View File
@@ -0,0 +1,206 @@
"""Read and write the EDN subset uml-viewer loads with clojure.edn."""
from __future__ import annotations
import re
def _escape(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
def _keyword_key(key: str) -> bool:
return bool(re.fullmatch(r"[A-Za-z*+!_?][A-Za-z0-9*+!_?\-./]*", key))
def dumps(value) -> str:
"""Render a Python value as EDN.
Dict keys that are plain identifiers become keywords. Other keys and all
ordinary strings become EDN strings. `True` and `False` are not used;
booleans here are the strings `true` and `false` only when passed through
`keyword`.
"""
return _render(value)
def keyword(name: str) -> tuple:
"""A value that renders as an EDN keyword."""
return ("keyword", name)
def _render(value) -> str:
if value is None:
return "nil"
if isinstance(value, tuple) and len(value) == 2 and value[0] == "keyword":
return f":{value[1]}"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int) and not isinstance(value, bool):
return str(value)
if isinstance(value, float):
text = f"{value:.4f}".rstrip("0").rstrip(".")
if "." not in text:
text += ".0"
return text
if isinstance(value, str):
return f'"{_escape(value)}"'
if isinstance(value, (list, tuple)):
body = " ".join(_render(item) for item in value)
return f"[{body}]" if body else "[]"
if isinstance(value, dict):
parts = []
for key, item in value.items():
rendered_key = f":{key}" if isinstance(key, str) and _keyword_key(key) else _render(key)
parts.append(f"{rendered_key} {_render(item)}")
body = " ".join(parts)
return "{" + body + "}"
raise TypeError(f"cannot render {type(value).__name__} as EDN")
class _Reader:
def __init__(self, text: str):
self.text = text
self.length = len(text)
self.index = 0
def parse(self):
self._skip()
if self.index >= self.length:
raise ValueError("empty EDN")
value = self._value()
self._skip()
if self.index < self.length:
raise ValueError(f"trailing EDN at {self.index}")
return value
def _peek(self) -> str:
if self.index >= self.length:
raise ValueError("unexpected end of EDN")
return self.text[self.index]
def _skip(self) -> None:
while self.index < self.length:
ch = self.text[self.index]
if ch.isspace() or ch == ",":
self.index += 1
continue
if ch == ";":
while self.index < self.length and self.text[self.index] != "\n":
self.index += 1
continue
break
def _value(self):
self._skip()
ch = self._peek()
if ch == '"':
return self._string()
if ch == ":":
return self._keyword()
if ch == "{":
return ("map", self._collection("}", self._map_item))
if ch == "[":
return ("vec", self._collection("]", self._element))
if ch == "(":
return ("list", self._collection(")", self._element))
if ch == "#":
self.index += 1
if self._peek() == "{":
return set(self._collection("}", self._element))
raise ValueError("unsupported EDN dispatch")
return self._atom()
def _collection(self, closing: str, item):
self.index += 1
values = []
while True:
self._skip()
if self._peek() == closing:
self.index += 1
return values
values.append(item())
def _element(self):
return self._value()
def _map_item(self):
key = self._value()
self._skip()
val = self._value()
return key, val
def _string(self) -> str:
self.index += 1
chars: list[str] = []
while self.index < self.length:
ch = self.text[self.index]
self.index += 1
if ch == '"':
return "".join(chars)
if ch == "\\":
if self.index >= self.length:
raise ValueError("unterminated string escape")
escaped = self.text[self.index]
self.index += 1
chars.append({"n": "\n", "t": "\t", "r": "\r", '"': '"', "\\": "\\"}.get(escaped, escaped))
continue
chars.append(ch)
raise ValueError("unterminated string")
def _keyword(self) -> str:
self.index += 1
start = self.index
while self.index < self.length and _symbol_char(self.text[self.index]):
self.index += 1
if self.index == start:
raise ValueError("empty keyword")
return self.text[start : self.index]
def _atom(self):
start = self.index
while self.index < self.length and _symbol_char(self.text[self.index]):
self.index += 1
token = self.text[start : self.index]
if token == "":
raise ValueError(f"unexpected character {self._peek()!r}")
if token == "nil":
return None
if token == "true":
return True
if token == "false":
return False
if re.fullmatch(r"-?\d+", token):
return int(token)
if re.fullmatch(r"-?\d+\.\d+", token):
return float(token)
return token
def _symbol_char(ch: str) -> bool:
return ch not in ' \t\r\n,{}[]()"\\;'
def _maps(value):
if isinstance(value, tuple) and len(value) == 2 and value[0] in {"map", "vec", "list"}:
kind, items = value
if kind == "map":
return {_key(key): _maps(item) for key, item in items}
return [_maps(item) for item in items]
if isinstance(value, set):
return {_maps(item) for item in value}
return value
def _key(value):
if isinstance(value, (str, int, float)) or value is None:
return value
return str(value)
def loads(text: str):
"""Parse one EDN value. Keywords become their names, without the colon."""
return _maps(_Reader(text).parse())
+370
View File
@@ -0,0 +1,370 @@
"""Apply each selected mutant, run tests, and record the snapshot."""
from __future__ import annotations
import sys
from pathlib import Path
from mutator.crapper_link import ensure_crapper
from mutator.functions import form_digests, form_spans, sites_in_file
from mutator.metrics import load_history, source_key, write_results
from mutator.model import FormResult, RunResult, Site
from mutator.report import format_scan
from mutator.runner import CommandRunner, test_plan
from mutator.sites import apply_site
def restore_backups(root: Path) -> list[Path]:
"""Put back sources left mutated by a killed run."""
base = root / "target" / "mutator-backup"
restored: list[Path] = []
if not base.is_dir():
return restored
for backup in sorted(path for path in base.rglob("*") if path.is_file()):
relative = backup.relative_to(base)
target = root / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(backup.read_bytes())
backup.unlink()
restored.append(target)
return restored
def _drop_bytecode(path: Path) -> None:
"""A same-second .pyc would hide the mutant from Python."""
cache = path.parent / "__pycache__"
if not cache.is_dir():
return
for item in cache.glob(path.stem + ".*.pyc"):
item.unlink(missing_ok=True)
def _backup(root: Path, path: Path, original: bytes) -> Path:
relative = path.resolve().relative_to(root.resolve())
backup = root / "target" / "mutator-backup" / relative
backup.parent.mkdir(parents=True, exist_ok=True)
if not backup.exists():
backup.write_bytes(original)
return backup
def _covered(site: Site, lines: set[int] | None, ignore_coverage: bool) -> bool:
if ignore_coverage:
return True
if lines is None:
return False
return site.line in lines
def _select(
sites: list[Site],
covered: dict[str, bool],
history,
digests: dict[tuple[str, str], str],
mutate_all: bool,
lines: set[int] | None,
) -> list[Site]:
chosen = []
for site in sites:
if not covered[site.mutation_id]:
continue
if lines is not None and site.line not in lines:
continue
key = (site.namespace, site.form_id)
prior = history.forms.get(key)
unchanged = prior is not None and prior.digest == digests.get(key)
if mutate_all or not unchanged:
chosen.append(site)
continue
if history.outcomes.get(site.mutation_id) == "killed":
continue
chosen.append(site)
return chosen
def _carry_forward(sites: list[Site], covered: dict[str, bool], history, digests) -> dict[str, str]:
by_id = {site.mutation_id: site for site in sites}
carried = {}
for mutation, status in history.outcomes.items():
site = by_id.get(mutation)
if site is None or not covered[site.mutation_id]:
continue
key = (site.namespace, site.form_id)
prior = history.forms.get(key)
if prior is not None and prior.digest == digests.get(key):
carried[mutation] = status
return carried
def _forms(
source: str,
path: Path,
root: Path,
file_key: str,
sites: list[Site],
covered: dict[str, bool],
outcomes: dict[str, str],
lines: set[int] | None,
) -> list[FormResult]:
spans = form_spans(source, path, root)
digests = form_digests(source, path, root)
grouped: dict[tuple[str, str], list[Site]] = {}
for site in sites:
grouped.setdefault((site.namespace, site.form_id), []).append(site)
forms = []
for key, (start, end, name, private) in sorted(spans.items(), key=lambda item: item[1][0]):
namespace, form = key
owned = grouped.get(key, [])
killed = survived = uncovered = 0
for site in owned:
if not covered[site.mutation_id]:
uncovered += 1
continue
status = outcomes.get(site.mutation_id)
if status == "killed":
killed += 1
elif status == "survived":
survived += 1
elif lines is None:
survived += 1
forms.append(
FormResult(
id=form,
namespace=namespace,
name=name,
private=private,
file=file_key,
line=start,
end_line=end,
digest=digests[key],
killed=killed,
survived=survived,
uncovered=uncovered,
sites=len(owned),
)
)
return forms
def _run_mutant(path: Path, root: Path, original: bytes, site: Site, run) -> str:
current = original[site.start : site.end].decode("utf-8")
if current != site.original:
print(f"Skipped {path}:{site.line} {site.description}; source bytes moved", file=sys.stderr)
return "survived"
backup = _backup(root, path, original)
mutated = apply_site(original.decode("utf-8"), site.start, site.end, site.mutant)
path.write_text(mutated, encoding="utf-8")
_drop_bytecode(path)
try:
return run()
finally:
path.write_bytes(original)
if path.read_bytes() == original and backup.is_file():
backup.unlink()
def _decode(path: Path) -> tuple[bytes, str] | None:
original = path.read_bytes()
try:
return original, original.decode("utf-8")
except UnicodeError:
return None
def _coverage_map(
found: list[Site], covered_lines: set[int] | None, ignore_coverage: bool
) -> dict[str, bool]:
return {site.mutation_id: _covered(site, covered_lines, ignore_coverage) for site in found}
def _warn_uncovered(file_key: str, found, covered_lines, ignore_coverage: bool) -> None:
if covered_lines is not None or ignore_coverage or not found:
return
print(
f"No coverage data for {file_key}; its sites are uncovered. "
"Pass --no-coverage to run them anyway.",
file=sys.stderr,
)
def _warn_many(file_key: str, selected, mutation_warning: int) -> None:
if len(selected) > mutation_warning:
print(
f"WARNING: {len(selected)} covered mutations selected in {file_key}.",
file=sys.stderr,
)
def _remember_baseline(baselines, cache_key, runner, command: str, cwd: Path) -> None:
if cache_key in baselines:
return
baseline = runner.run(command, cwd, None)
if baseline.code != 0:
tail = "\n".join(baseline.output.splitlines()[-20:])
baselines[cache_key] = (False, baseline.seconds, tail)
return
baselines[cache_key] = (True, baseline.seconds, "")
def _baseline_failure(file_key: str, command: str, tail: str) -> RunResult:
message = f"Baseline failed for {file_key}: {command}"
if tail:
message = f"{message}\n{tail}"
return RunResult(path=file_key, forms=[], written=[], baseline_failed=True, baseline_message=message)
def _mutant_status(runner, command: str, cwd: Path, timeout: float) -> str:
result = runner.run(command, cwd, timeout)
if result.timed_out or result.code != 0:
return "killed"
return "survived"
def _run_selected(path, root, original, selected, runner, command, cwd, timeout, file_key, outcomes) -> None:
for site in selected:
if runner.verbose:
print(f"{file_key}:{site.line} {site.description}", file=sys.stderr)
outcomes[site.mutation_id] = _run_mutant(
path,
root,
original,
site,
lambda: _mutant_status(runner, command, cwd, timeout),
)
def _apply_selected(
path,
root,
original,
selected,
runner,
language,
test_command,
timeout_factor,
file_key,
baselines,
outcomes,
) -> RunResult | None:
if not selected:
return None
command, cwd = test_plan(root, path, language, test_command)
cache_key = (command, str(cwd))
_remember_baseline(baselines, cache_key, runner, command, cwd)
ok, seconds, tail = baselines[cache_key]
if not ok:
return _baseline_failure(file_key, command, tail)
timeout = max(2.0, seconds * timeout_factor)
_run_selected(path, root, original, selected, runner, command, cwd, timeout, file_key, outcomes)
return None
def _statuses(found: list[Site], covered: dict[str, bool], outcomes: dict[str, str]) -> dict[str, str]:
statuses = {}
for site in found:
if not covered[site.mutation_id]:
statuses[site.mutation_id] = "uncovered"
elif site.mutation_id in outcomes:
statuses[site.mutation_id] = outcomes[site.mutation_id]
return statuses
def _restore_if_dirty(path: Path, original: bytes) -> None:
if path.read_bytes() != original:
path.write_bytes(original)
def mutate_file(
path: Path,
root: Path,
*,
runner: CommandRunner,
covered_lines: set[int] | None,
ignore_coverage: bool,
mutate_all: bool,
lines: set[int] | None,
test_command: str | None,
timeout_factor: float,
mutation_warning: int,
baselines: dict[tuple[str, str], tuple[bool, float, str]],
) -> RunResult:
"""Mutate one file and write its namespaces into `.metrics/mutate`."""
root = root.resolve()
path = path.resolve()
crapper = ensure_crapper()
language = crapper.discover.language_of(path)
file_key = source_key(path, root)
if language is None:
return RunResult(path=file_key, forms=[], written=[], skipped="unsupported file")
decoded = _decode(path)
if decoded is None:
return RunResult(path=file_key, forms=[], written=[], skipped="file is not UTF-8")
original, source = decoded
found = sites_in_file(source, path, root, file_key)
covered = _coverage_map(found, covered_lines, ignore_coverage)
_warn_uncovered(file_key, found, covered_lines, ignore_coverage)
history = load_history(root, file_key)
digests = form_digests(source, path, root)
selected = _select(found, covered, history, digests, mutate_all, lines)
outcomes = _carry_forward(found, covered, history, digests)
_warn_many(file_key, selected, mutation_warning)
failure = _apply_selected(
path,
root,
original,
selected,
runner,
language,
test_command,
timeout_factor,
file_key,
baselines,
outcomes,
)
if failure is not None:
return failure
forms = _forms(source, path, root, file_key, found, covered, outcomes, lines)
written = write_results(root, file_key, forms, outcomes)
_restore_if_dirty(path, original)
return RunResult(
path=file_key,
forms=forms,
written=[item.as_posix() for item in written],
sites=found,
statuses=_statuses(found, covered, outcomes),
)
def scan_file(
path: Path,
root: Path,
*,
covered_lines: set[int] | None,
ignore_coverage: bool,
lines: set[int] | None,
) -> str:
"""Inventory mutation sites without running tests or writing metrics."""
root = root.resolve()
path = path.resolve()
file_key = source_key(path, root)
source = path.read_text(encoding="utf-8")
found = sites_in_file(source, path, root, file_key)
if lines is not None:
found = [site for site in found if site.line in lines]
covered = None
if not ignore_coverage and covered_lines is not None:
covered = {site.mutation_id: site.line in covered_lines for site in found}
history = load_history(root, file_key)
digests = form_digests(source, path, root)
changed = set()
if history.forms:
changed = {
key
for key, digest in digests.items()
if key not in history.forms or history.forms[key].digest != digest
}
return format_scan(file_key, found, changed, covered)
+195
View File
@@ -0,0 +1,195 @@
"""Bind mutation sites to the functions crapper reports."""
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
from mutator.crapper_link import ensure_crapper
from mutator.model import Site
from mutator.sites import discover_raw
def _lines(source: str) -> list[str]:
return source.splitlines()
def _slice(source: str, start: int, end: int) -> str:
lines = _lines(source)
return "\n".join(lines[start - 1 : end])
def digest(source: str, start: int, end: int) -> str:
chunk = "\n".join(line.rstrip() for line in _lines(source)[start - 1 : end])
return hashlib.sha256(chunk.encode("utf-8")).hexdigest()
def _header(source: str, start: int, end: int) -> str:
text = _slice(source, start, min(end, start + 6)).lstrip()
while text.startswith("#["):
close = text.find("]")
if close < 0:
break
text = text[close + 1 :].lstrip()
return text
def _python_private(name: str) -> bool:
if name.startswith("__") and name.endswith("__"):
return False
return name.startswith("_")
def _typescript_private(name: str, header: str) -> bool:
return name.startswith("#") or bool(re.search(r"\bprivate\b", header))
def is_private(language: str, name: str, source: str, start: int, end: int) -> bool:
"""True when uml-viewer should treat the operation as private."""
header = _header(source, start, end)
if language == "clojure":
return bool(re.match(r"\(defn-", header))
if language == "java":
return bool(re.search(r"\bprivate\b", header))
if language == "go":
return name[:1].islower()
if language == "python":
return _python_private(name)
if language == "rust":
return re.match(r"pub\s", header) is None
if language == "typescript":
return _typescript_private(name, header)
return False
def form_id(name: str, private: bool) -> str:
"""The id uml-viewer splits into an operation name.
`defn/place` is public. `defn-/hide` is private. The viewer checks the
private prefix first.
"""
prefix = "defn-" if private else "defn"
return f"{prefix}/{name}"
def mutation_id(
file: str, namespace: str, form: str, start: int, end: int, original: str, mutant: str
) -> str:
return json.dumps(
[file, namespace, form, start, end, original, mutant], separators=(",", ":")
)
def _mutation_field(mutation: str, index: int) -> str | None:
try:
value = json.loads(mutation)
except json.JSONDecodeError:
return None
if isinstance(value, list) and len(value) > index and isinstance(value[index], str):
return value[index]
return None
def mutation_file(mutation: str) -> str | None:
return _mutation_field(mutation, 0)
def mutation_namespace(mutation: str) -> str | None:
return _mutation_field(mutation, 1)
def project_functions(source: str, path: Path, root: Path):
crapper = ensure_crapper()
language = crapper.discover.language_of(path)
if language is None:
return []
return crapper.languages.functions_in_file(
language,
source,
path.resolve().as_posix(),
root.resolve().as_posix(),
)
def _owner(functions, line: int):
matches = [fn for fn in functions if fn.start_line <= line <= fn.end_line]
if not matches:
return None
return min(matches, key=lambda fn: (fn.end_line - fn.start_line, fn.start_line))
def sites_in_file(source: str, path: Path, root: Path, file_key: str) -> list[Site]:
crapper = ensure_crapper()
language = crapper.discover.language_of(path)
if language is None:
return []
functions = project_functions(source, path, root)
privacy = {
id(fn): is_private(language, fn.name, source, fn.start_line, fn.end_line) for fn in functions
}
found: list[Site] = []
for raw in discover_raw(source, language, path.as_posix()):
owner = _owner(functions, raw.line)
if owner is None:
continue
private = privacy[id(owner)]
form = form_id(owner.name, private)
found.append(
Site(
file=file_key,
namespace=owner.namespace,
form_id=form,
name=owner.name,
line=raw.line,
start=raw.start,
end=raw.end,
original=raw.original,
mutant=raw.mutant,
category=raw.category,
mutation_id=mutation_id(
file_key, owner.namespace, form, raw.start, raw.end, raw.original, raw.mutant
),
)
)
return found
def form_digests(source: str, path: Path, root: Path) -> dict[tuple[str, str], str]:
"""Map `(namespace, form id)` to a hash of the function text."""
crapper = ensure_crapper()
language = crapper.discover.language_of(path)
if language is None:
return {}
grouped: dict[tuple[str, str], list] = {}
for fn in project_functions(source, path, root):
private = is_private(language, fn.name, source, fn.start_line, fn.end_line)
key = (fn.namespace, form_id(fn.name, private))
grouped.setdefault(key, []).append(fn)
digests = {}
for key, fns in grouped.items():
ordered = sorted(fns, key=lambda fn: (fn.start_line, fn.end_line))
parts = [digest(source, fn.start_line, fn.end_line) for fn in ordered]
digests[key] = hashlib.sha256("".join(parts).encode("utf-8")).hexdigest()
return digests
def form_spans(source: str, path: Path, root: Path) -> dict[tuple[str, str], tuple[int, int, str, bool]]:
"""`(namespace, form id)` -> start line, end line, name, private."""
crapper = ensure_crapper()
language = crapper.discover.language_of(path)
spans = {}
for fn in project_functions(source, path, root):
private = is_private(language or "", fn.name, source, fn.start_line, fn.end_line)
key = (fn.namespace, form_id(fn.name, private))
if key not in spans:
spans[key] = (fn.start_line, fn.end_line, fn.name, private)
continue
start, end, name, private_flag = spans[key]
spans[key] = (min(start, fn.start_line), max(end, fn.end_line), name, private_flag)
return spans
+344
View File
@@ -0,0 +1,344 @@
"""Read and write `.metrics/mutate` snapshots.
uml-viewer loads every `*.edn` file under `.metrics/mutate`. Each file is one
namespace. It joins a form to an operation when the form id is `defn/<name>`
or `defn-/<name>`, and it joins the file to a class on `:namespace`.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from mutator.edn import dumps, keyword, loads
from mutator.functions import mutation_file, mutation_namespace
from mutator.model import FormResult, History, PriorForm
def _relative(path: Path, root: Path) -> str:
return path.resolve().relative_to(root.resolve()).as_posix()
def source_key(path: Path, root: Path) -> str:
return _relative(path, root)
def snapshot_path(root: Path, namespace: str) -> Path:
parts = namespace.replace("\\", "/").replace("::", "/").split("/")
safe = []
for part in parts:
if part in {"", ".", ".."}:
raise ValueError(f"unsafe namespace {namespace!r}")
safe.append(part)
return root.joinpath(".metrics", "mutate", *safe[:-1], safe[-1] + ".edn")
def _edn_files(root: Path) -> list[Path]:
directory = root / ".metrics" / "mutate"
if not directory.is_dir():
return []
return sorted(path for path in directory.rglob("*.edn") if path.is_file())
def _read(path: Path) -> dict | None:
try:
data = loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def _forms_of(data: dict) -> list[dict]:
forms = data.get("forms")
if not isinstance(forms, list):
return []
return [form for form in forms if isinstance(form, dict)]
def _outcomes_of(data: dict) -> dict[str, str]:
raw = data.get("outcomes")
if not isinstance(raw, dict):
return {}
found = {}
for key, value in raw.items():
if not isinstance(key, str):
continue
status = value[1:] if isinstance(value, str) and value.startswith(":") else value
if status in {"killed", "survived"}:
found[key] = status
return found
def _form_file(form: dict, data: dict) -> str | None:
file_name = form.get("file")
if isinstance(file_name, str) and file_name:
return file_name
source = data.get("source")
return source if isinstance(source, str) else None
def _remember_form(history: History, namespace: str, form: dict, data: dict, file_key: str) -> None:
if _form_file(form, data) != file_key:
return
form_id = form.get("id")
digest = form.get("hash")
if not isinstance(form_id, str) or not isinstance(digest, str):
return
history.forms[(namespace, form_id)] = PriorForm(
namespace=namespace,
id=form_id,
digest=digest,
file=file_key,
)
def _remember_forms(history: History, data: dict, namespace: str, file_key: str) -> None:
for form in _forms_of(data):
_remember_form(history, namespace, form, data, file_key)
def _remember_outcomes(history: History, data: dict, file_key: str) -> None:
for mutation, status in _outcomes_of(data).items():
if mutation_file(mutation) == file_key:
history.outcomes[mutation] = status
def load_history(root: Path, file_key: str) -> History:
"""Prior form hashes and outcomes recorded for `file_key`."""
history = History()
for path in _edn_files(root):
data = _read(path)
if data is None:
continue
namespace = data.get("namespace")
if not isinstance(namespace, str):
continue
_remember_forms(history, data, namespace, file_key)
_remember_outcomes(history, data, file_key)
return history
def _render_form(form: FormResult) -> dict:
return {
"id": form.id,
"kind": "defn-" if form.private else "defn",
"file": form.file,
"line": form.line,
"end-line": form.end_line,
"hash": form.digest,
"killed": form.killed,
"survived": form.survived,
"uncovered": form.uncovered,
"sites": form.sites,
}
def _render_snapshot(namespace: str, source: str, forms: list[dict], outcomes: dict[str, str]) -> str:
payload = {
"version": 1,
"tested-at": datetime.now().astimezone().isoformat(timespec="seconds"),
"source": source,
"namespace": namespace,
"outcomes": {key: keyword(outcomes[key]) for key in sorted(outcomes)},
"forms": forms,
}
text = dumps(payload)
# Keep the file readable. dumps is one line; break after the top-level pairs
# is unnecessary because clojure.edn reads either shape.
return text + "\n"
def _kept_forms(data: dict, file_key: str) -> list[dict]:
return [form for form in _forms_of(data) if _form_file(form, data) != file_key]
def _kept_outcomes(data: dict, file_key: str) -> dict[str, str]:
return {
mutation: status
for mutation, status in _outcomes_of(data).items()
if mutation_file(mutation) != file_key
}
def _group_forms(forms: list[FormResult]) -> dict[str, list[FormResult]]:
incoming: dict[str, list[FormResult]] = {}
for form in forms:
incoming.setdefault(form.namespace, []).append(form)
return incoming
def _file_outcomes(outcomes: dict[str, str], file_key: str) -> dict[str, str]:
found = {}
for mutation, status in outcomes.items():
if mutation_file(mutation) != file_key:
continue
if status not in {"killed", "survived"}:
continue
found[mutation] = status
return found
def _read_snapshots(root: Path) -> list[tuple[Path, dict]]:
existing = []
for path in _edn_files(root):
data = _read(path)
if data is not None:
existing.append((path, data))
return existing
def _mentions_file(data: dict, file_key: str) -> bool:
for form in _forms_of(data):
if _form_file(form, data) == file_key:
return True
return False
def _keep(retained: dict, namespace: str, data: dict, file_key: str) -> None:
kept_forms = _kept_forms(data, file_key)
kept_outcomes = _kept_outcomes(data, file_key)
if not kept_forms and not kept_outcomes:
return
forms_so_far, outcomes_so_far = retained.get(namespace, ([], {}))
forms_so_far.extend(kept_forms)
outcomes_so_far.update(kept_outcomes)
retained[namespace] = (forms_so_far, outcomes_so_far)
def _retain(existing, file_key: str):
retained: dict[str, tuple[list[dict], dict[str, str]]] = {}
previous: set[str] = set()
for _path, data in existing:
namespace = data.get("namespace")
if not isinstance(namespace, str):
continue
if _mentions_file(data, file_key):
previous.add(namespace)
_keep(retained, namespace, data, file_key)
return retained, previous
def _outcomes_for(outcomes: dict[str, str], namespace: str) -> dict[str, str]:
found = {}
for mutation, status in outcomes.items():
if mutation_namespace(mutation) == namespace:
found[mutation] = status
return found
def _form_order(form: dict) -> tuple:
return (str(form.get("file")), int(form.get("line") or 0), str(form.get("id")))
def _snapshot_source(fresh: list[FormResult], kept_forms: list[dict], file_key: str) -> str:
if fresh:
return fresh[0].file
return str(kept_forms[0].get("file") or file_key)
def _write_namespace(
root: Path,
namespace: str,
kept_forms: list[dict],
kept_outcomes: dict[str, str],
fresh: list[FormResult],
outcomes: dict[str, str],
file_key: str,
) -> Path | None:
rendered = _dedupe(kept_forms + [_render_form(form) for form in fresh])
rendered.sort(key=_form_order)
merged = dict(kept_outcomes)
if fresh:
merged.update(_outcomes_for(outcomes, namespace))
path = snapshot_path(root, namespace)
if not rendered and not merged:
path.unlink(missing_ok=True)
return None
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
_render_snapshot(namespace, _snapshot_source(fresh, kept_forms, file_key), rendered, merged),
encoding="utf-8",
)
return path
def _stale(path: Path, data: dict, touched: set[str], canonical: set[Path], file_key: str) -> bool:
if path.resolve() in canonical:
return False
namespace = data.get("namespace")
if isinstance(namespace, str) and namespace in touched:
return True
return _form_file_only(data, file_key)
def _drop_stale(existing, touched: set[str], canonical: set[Path], file_key: str) -> None:
"""Remove a per-file snapshot so the viewer cannot prefer it over the namespace file."""
for path, data in existing:
if _stale(path, data, touched, canonical, file_key):
path.unlink(missing_ok=True)
def write_results(
root: Path, file_key: str, forms: list[FormResult], outcomes: dict[str, str]
) -> list[Path]:
"""Replace this file's forms, preserving other files that share a namespace.
One snapshot file per namespace. Older per-file snapshots for a namespace
this write touches are folded in and removed, so uml-viewer sees a single
map for that class.
"""
root = root.resolve()
incoming = _group_forms(forms)
incoming_outcomes = _file_outcomes(outcomes, file_key)
existing = _read_snapshots(root)
retained, previous = _retain(existing, file_key)
touched = set(incoming) | previous | set(retained)
written: list[Path] = []
for namespace in sorted(touched):
kept_forms, kept_outcomes = retained.get(namespace, ([], {}))
path = _write_namespace(
root,
namespace,
kept_forms,
kept_outcomes,
incoming.get(namespace, []),
incoming_outcomes,
file_key,
)
if path is not None:
written.append(path)
canonical = {snapshot_path(root, namespace).resolve() for namespace in touched}
_drop_stale(existing, touched, canonical, file_key)
_remove_empty_dirs(root / ".metrics" / "mutate")
return written
def _form_file_only(data: dict, file_key: str) -> bool:
forms = _forms_of(data)
if not forms:
return data.get("source") == file_key
files = {_form_file(form, data) for form in forms}
return files == {file_key}
def _dedupe(forms: list[dict]) -> list[dict]:
seen = set()
unique = []
for form in forms:
key = (form.get("file"), form.get("id"), form.get("line"), form.get("hash"))
if key in seen:
continue
seen.add(key)
unique.append(form)
return unique
def _remove_empty_dirs(directory: Path) -> None:
if not directory.is_dir():
return
for child in sorted(directory.rglob("*"), reverse=True):
if child.is_dir() and not any(child.iterdir()):
child.rmdir()
+81
View File
@@ -0,0 +1,81 @@
"""Mutation sites and the per-function counts uml-viewer reads."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Site:
"""One replacement inside a function crapper would score."""
file: str
namespace: str
form_id: str
name: str
line: int
start: int
end: int
original: str
mutant: str
category: str
mutation_id: str
@property
def description(self) -> str:
if self.mutant == "":
return f"delete {self.original}"
return f"{self.original} -> {self.mutant}"
@dataclass
class FormResult:
"""Killed, survived, and uncovered totals for one operation."""
id: str
namespace: str
name: str
private: bool
file: str
line: int
end_line: int
digest: str
killed: int = 0
survived: int = 0
uncovered: int = 0
sites: int = 0
@property
def score(self) -> float | None:
executed = self.killed + self.survived
if executed == 0:
return None
return self.killed / executed
@dataclass
class PriorForm:
namespace: str
id: str
digest: str
file: str
@dataclass
class History:
"""Previous results for one source file, used for differential runs."""
forms: dict[tuple[str, str], PriorForm] = field(default_factory=dict)
outcomes: dict[str, str] = field(default_factory=dict)
@dataclass
class RunResult:
path: str
forms: list[FormResult]
written: list[str]
sites: list[Site] = field(default_factory=list)
statuses: dict[str, str] = field(default_factory=dict)
baseline_failed: bool = False
baseline_message: str = ""
skipped: str = ""
+74
View File
@@ -0,0 +1,74 @@
"""Text reports for a mutation run and for --scan."""
from __future__ import annotations
from mutator.model import FormResult, Site
def _score(form: FormResult) -> str:
if form.score is None:
return " n/a"
return f"{form.score * 100:5.1f}%"
def format_results(forms: list[FormResult]) -> str:
if not forms:
return "No functions to mutate.\n"
name_width = max(8, min(48, max(len(form.name) for form in forms)))
namespace_width = max(9, min(72, max(len(form.namespace) for form in forms)))
header = (
f"{'Function':<{name_width}} {'Namespace':<{namespace_width}} "
f"{'Killed':>7} {'Survived':>9} {'Uncovered':>10} {'Sites':>6} {'Score':>7}"
)
ordered = sorted(
forms,
key=lambda form: (form.score is not None, form.score or 0, -form.survived, form.name),
)
lines = [header, "-" * len(header)]
killed = survived = uncovered = sites = 0
for form in ordered:
lines.append(
f"{form.name:<{name_width}} {form.namespace:<{namespace_width}} "
f"{form.killed:7d} {form.survived:9d} {form.uncovered:10d} {form.sites:6d} {_score(form)}"
)
killed += form.killed
survived += form.survived
uncovered += form.uncovered
sites += form.sites
executed = killed + survived
score = "n/a" if executed == 0 else f"{(killed / executed) * 100:.1f}%"
lines.append("-" * len(header))
lines.append(
f"{'Total':<{name_width}} {'':<{namespace_width}} "
f"{killed:7d} {survived:9d} {uncovered:10d} {sites:6d} {score:>7}"
)
lines.append("")
return "\n".join(lines)
def format_site_log(sites: list[Site], status: dict[str, str]) -> str:
lines = []
for site in sites:
label = status.get(site.mutation_id)
if label is None:
continue
lines.append(f"{label.upper():9} {site.file}:{site.line} {site.description}")
if not lines:
return ""
return "\n".join(lines) + "\n"
def format_scan(
path: str, sites: list[Site], changed: set[tuple[str, str]], covered: dict[str, bool] | None
) -> str:
lines = [f"Scan: {len(sites)} mutation sites in {path}"]
for site in sites:
mark = "*" if (site.namespace, site.form_id) in changed else " "
coverage = ""
if covered is not None and not covered.get(site.mutation_id, True):
coverage = " uncovered"
lines.append(f"{mark} {path}:{site.line} {site.description}{coverage} [{site.form_id}]")
if changed:
lines.append("* marks a function whose text changed since the last snapshot.")
lines.append("")
return "\n".join(lines)
+133
View File
@@ -0,0 +1,133 @@
"""Run a project's tests, and decide the command for each language."""
from __future__ import annotations
import os
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class CommandResult:
code: int
timed_out: bool
seconds: float
output: str
class CommandRunner:
def __init__(self, verbose: bool = False):
self.verbose = verbose
def run(self, command: str, cwd: Path, timeout: float | None) -> CommandResult:
if self.verbose:
print(f"+ ({cwd}) {command}", file=sys.stderr)
started = time.monotonic()
environment = os.environ.copy()
environment["PYTHONDONTWRITEBYTECODE"] = "1"
process = subprocess.Popen(
command,
cwd=cwd,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
start_new_session=True,
text=True,
env=environment,
)
try:
output, _err = process.communicate(timeout=timeout)
code = process.returncode if process.returncode is not None else 1
timed_out = False
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
output, _err = process.communicate()
code = 124
timed_out = True
seconds = time.monotonic() - started
return CommandResult(code=code, timed_out=timed_out, seconds=seconds, output=output or "")
def nearest(start: Path, marker: str, stop: Path) -> Path | None:
current = start if start.is_dir() else start.parent
stop = stop.resolve()
current = current.resolve()
while True:
if (current / marker).is_file():
return current
if current == stop or current.parent == current:
return None
current = current.parent
def _read(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
except OSError:
return ""
def _clojure_command(directory: Path) -> str:
deps = directory / "deps.edn"
bb = directory / "bb.edn"
if bb.is_file() and not deps.is_file():
text = _read(bb)
if "spec" in text:
return "bb spec --tag ~no-mutate"
return "bb test"
text = _read(deps)
if ":spec" in text and "speclj" in text:
return "clj -M:spec --tag ~no-mutate"
if ":spec" in text:
return "clj -M:spec"
return "clj -M:test"
def _python_command(directory: Path) -> str:
pyproject = _read(directory / "pyproject.toml")
if (
(directory / "pytest.ini").is_file()
or (directory / "conftest.py").is_file()
or "pytest" in pyproject
or (directory / "setup.cfg").is_file() and "pytest" in _read(directory / "setup.cfg")
):
return f"{sys.executable} -m pytest"
return f"{sys.executable} -m unittest discover"
def test_plan(root: Path, source: Path, language: str, override: str | None) -> tuple[str, Path]:
"""The test command and the directory it runs in."""
root = root.resolve()
if override:
return override, root
if language == "clojure":
directory = nearest(source, "deps.edn", root) or nearest(source, "bb.edn", root) or root
return _clojure_command(directory), directory
if language == "java":
directory = nearest(source, "pom.xml", root) or root
return "mvn -q test -DexcludeTags=no-mutate", directory
if language == "go":
directory = nearest(source, "go.mod", root) or root
relative = source.resolve().parent.relative_to(directory.resolve())
package = "." if relative == Path(".") else "./" + relative.as_posix()
return f"go test -count=1 {package}", directory
if language == "typescript":
directory = nearest(source, "package.json", root) or root
return "npm test", directory
if language == "rust":
directory = nearest(source, "Cargo.toml", root) or root
return "cargo test", directory
if language == "python":
directory = (
nearest(source, "pyproject.toml", root)
or nearest(source, "pytest.ini", root)
or nearest(source, "setup.cfg", root)
or root
)
return _python_command(directory), directory
return "false", root
+254
View File
@@ -0,0 +1,254 @@
"""Find mutation sites.
Clojure follows clj-mutate's symbol rules. Go follows mutate4go, including
one-way `*` to `/`. Java follows mutate4java. TypeScript, Rust, and Python
use the same decisions as Java, spelled in that language.
"""
from __future__ import annotations
from mutator.crapper_link import ensure_crapper
_BINARY = {
"+": "-",
"-": "+",
"*": "/",
"/": "*",
">": ">=",
">=": ">",
"<": "<=",
"<=": "<",
"==": "!=",
"!=": "==",
"===": "!==",
"!==": "===",
"&&": "||",
"||": "&&",
"and": "or",
"or": "and",
}
_BINARY_PARENTS = {
"binary_expression",
"binary_operator",
"comparison_operator",
"boolean_operator",
}
_UNARY_PARENTS = {"unary_expression", "unary_operator", "not_operator"}
_BOOLEANS = {"true": "false", "false": "true", "True": "False", "False": "True"}
_NUMBERS = {
"decimal_integer_literal",
"integer_literal",
"int_literal",
"integer",
"number",
"num_lit",
}
_CLOJURE_HEAD = {
"+": ("-", "arithmetic"),
"-": ("+", "arithmetic"),
"*": ("/", "arithmetic"),
"inc": ("dec", "arithmetic"),
"dec": ("inc", "arithmetic"),
">": (">=", "comparison"),
">=": (">", "comparison"),
"<": ("<=", "comparison"),
"<=": ("<", "comparison"),
"=": ("not=", "equality"),
"not=": ("=", "equality"),
"if": ("if-not", "conditional"),
"if-not": ("if", "conditional"),
"when": ("when-not", "conditional"),
"when-not": ("when", "conditional"),
"and": ("or", "logical"),
"or": ("and", "logical"),
"double": ("int", "coercion"),
"int": ("double", "coercion"),
"first": ("second", "seq"),
"second": ("first", "seq"),
"filter": ("remove", "seq"),
"remove": ("filter", "seq"),
"take": ("drop", "seq"),
"drop": ("take", "seq"),
"rest": ("next", "seq"),
"next": ("rest", "seq"),
"every?": ("some", "seq"),
"some": ("every?", "seq"),
"min": ("max", "numeric"),
"max": ("min", "numeric"),
"pos?": ("neg?", "predicate"),
"neg?": ("pos?", "predicate"),
"even?": ("odd?", "predicate"),
"odd?": ("even?", "predicate"),
"nil?": ("some?", "predicate"),
"some?": ("nil?", "predicate"),
}
class RawSite:
def __init__(self, line: int, start: int, end: int, original: str, mutant: str, category: str):
self.line = line
self.start = start
self.end = end
self.original = original
self.mutant = mutant
self.category = category
def _grammar(language: str, path: str) -> str:
if language == "typescript" and path.endswith(".tsx"):
return "tsx"
if language == "go":
return "go"
return language
def _text(data: bytes, node) -> str:
return data[node.start_byte : node.end_byte].decode("utf-8")
def _category(token: str) -> str:
if token in {"+", "-", "*", "/"}:
return "arithmetic"
if token in {">", ">=", "<", "<="}:
return "comparison"
if token in {"==", "!=", "===", "!=="}:
return "equality"
if token in {"&&", "||", "and", "or"}:
return "logical"
return "arithmetic"
def _binary_mutant(language: str, token: str) -> str | None:
if language == "go" and token == "/":
return None
return _BINARY.get(token)
def _add(found: list[RawSite], node, original: str, mutant: str, category: str) -> None:
if mutant == original:
return
found.append(
RawSite(
line=node.start_point[0] + 1,
start=node.start_byte,
end=node.end_byte,
original=original,
mutant=mutant,
category=category,
)
)
def _clojure_head(node, data: bytes) -> str | None:
for child in node.children:
if child.type == "sym_lit":
return _text(data, child)
return None
def _is_head(node) -> bool:
parent = node.parent
if parent is None or parent.type != "list_lit":
return False
for child in parent.children:
if child.type == "sym_lit":
return child.id == node.id
return False
def _clojure_skipped(node, data: bytes) -> bool:
current = node
while current is not None:
if any(part in current.type for part in ("comment", "quot", "string", "str_lit", "char_lit")):
return True
if current.type == "list_lit":
head = _clojure_head(current, data)
if head in {"comment", "quote"}:
return True
current = current.parent
return False
def _clojure_text(node, data: bytes) -> str:
if node.child_count == 0 or node.type in {"sym_lit", "bool_lit", "num_lit"}:
return _text(data, node)
return ""
def _flip01(text: str) -> str:
if text == "0":
return "1"
return "0"
def _clojure_node(node, data: bytes, found: list[RawSite]) -> None:
if _clojure_skipped(node, data):
return
text = _clojure_text(node, data)
if node.type == "sym_lit" and _is_head(node) and text in _CLOJURE_HEAD:
mutant, category = _CLOJURE_HEAD[text]
_add(found, node, text, mutant, category)
return
if node.type == "bool_lit" and text in _BOOLEANS:
_add(found, node, text, _BOOLEANS[text], "boolean")
return
if node.type == "num_lit" and text in {"0", "1"}:
_add(found, node, text, _flip01(text), "constant")
def _visit_clojure(node, data: bytes, found: list[RawSite]) -> None:
_clojure_node(node, data, found)
for child in node.children:
_visit_clojure(child, data, found)
def _visit(node, data: bytes, language: str, found: list[RawSite]) -> None:
parent = node.parent.type if node.parent is not None else ""
if node.child_count == 0:
token = _text(data, node)
# A literal sits inside the same expression as an operator, so these
# checks are independent. `0` is a child of `a > 0`, not an operator.
if parent in _BINARY_PARENTS:
mutant = _binary_mutant(language, token)
if mutant is not None:
_add(found, node, token, mutant, _category(token))
if token in {"!", "not"} and parent in _UNARY_PARENTS | {"comparison_operator"}:
_add(found, node, token, "", "unary")
if token == "-" and parent in _UNARY_PARENTS:
_add(found, node, token, "", "unary")
if node.type in {"true", "false"} and token in _BOOLEANS:
_add(found, node, token, _BOOLEANS[token], "boolean")
if node.type in _NUMBERS and token in {"0", "1"}:
_add(found, node, token, "1" if token == "0" else "0", "constant")
elif node.type == "bool_lit" and _text(data, node) in _BOOLEANS:
text = _text(data, node)
_add(found, node, text, _BOOLEANS[text], "boolean")
for child in node.children:
_visit(child, data, language, found)
def discover_raw(source: str, language: str, path: str) -> list[RawSite]:
"""Mutation sites in source order. Offsets are UTF-8 byte offsets."""
crapper = ensure_crapper()
data = source.encode("utf-8")
grammar = _grammar(language, path)
_data, tree = crapper.languages.treesitter.parse(source, grammar)
found: list[RawSite] = []
if language == "clojure":
_visit_clojure(tree.root_node, data, found)
else:
_visit(tree.root_node, data, language, found)
found.sort(key=lambda site: (site.line, site.start, site.end))
return found
def apply_site(source: str, start: int, end: int, mutant: str) -> str:
data = source.encode("utf-8")
updated = data[:start] + mutant.encode("utf-8") + data[end:]
return updated.decode("utf-8")
+94
View File
@@ -0,0 +1,94 @@
import subprocess
import sys
from mutator.cli import parse_args, run
from mutator.runner import CommandResult
def test_lines_option_parses_positive_numbers():
options = parse_args(["--lines", "2, 4", "src/demo.py"])
assert options.lines == {2, 4}
def test_changed_selects_a_dirty_source_file(tmp_path, monkeypatch, capsys):
path = tmp_path / "src" / "demo.py"
path.parent.mkdir()
path.write_text("def place(x):\n return x > 0\n", encoding="utf-8")
subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True)
subprocess.run(["git", "add", "src/demo.py"], cwd=tmp_path, check=True, capture_output=True)
monkeypatch.chdir(tmp_path)
code = run(["--scan", "--no-coverage", "--changed", "--root", str(tmp_path)])
captured = capsys.readouterr()
assert code == 0
assert "demo.py:2" in captured.out
def test_a_directory_argument_skips_a_named_test_file(tmp_path, capsys):
src = tmp_path / "pkg" / "app.py"
src.parent.mkdir()
src.write_text("def place(x):\n return x > 0\n", encoding="utf-8")
test = tmp_path / "pkg" / "test_app.py"
test.write_text("def test_place():\n assert True\n", encoding="utf-8")
code = run(
["--scan", "--no-coverage", "--root", str(tmp_path), str(tmp_path / "pkg"), str(test)]
)
captured = capsys.readouterr()
assert code == 0
assert "Skipping test file" in captured.err
assert "app.py:2" in captured.out
def test_help_and_conflicting_flags():
help_options = parse_args(["--help"])
assert help_options.exit_code == 0
assert "uml-viewer" in help_options.message
conflict = parse_args(["--mutate-all", "--since-last-run", "src/demo.py"])
assert conflict.exit_code == 1
assert run(["--help"]) == 0
def test_scan_lists_sites_without_writing_metrics(tmp_path, monkeypatch):
path = tmp_path / "src" / "demo.py"
path.parent.mkdir()
path.write_text("def place(x):\n return x > 0\n", encoding="utf-8")
monkeypatch.chdir(tmp_path)
code = run(["--scan", "--no-coverage", "--root", str(tmp_path), "src/demo.py"])
assert code == 0
assert not (tmp_path / ".metrics").exists()
def test_real_python_mutants_update_the_snapshot(tmp_path, capsys):
path = tmp_path / "src" / "demo.py"
path.parent.mkdir()
path.write_text(
"def add(a, b):\n"
" if a > 0:\n"
" return a + b\n"
" return 0\n",
encoding="utf-8",
)
command = (
f"{sys.executable} -c \"import sys; sys.path.insert(0, 'src'); "
"from demo import add; raise SystemExit(0 if add(2, 3) == 5 else 1)\""
)
code = run(
[
"--no-coverage",
"--mutate-all",
"--root",
str(tmp_path),
"--test-command",
command,
"src/demo.py",
]
)
captured = capsys.readouterr()
assert code == 3
assert "SURVIVED" in captured.out
assert "KILLED" in captured.out
snapshot = tmp_path / ".metrics" / "mutate" / "demo.edn"
assert snapshot.is_file()
text = snapshot.read_text(encoding="utf-8")
assert ":namespace \"demo\"" in text
assert ":id \"defn/add\"" in text
assert path.read_text(encoding="utf-8").startswith("def add")
+57
View File
@@ -0,0 +1,57 @@
from mutator.coverage import covered_lines
def test_jacoco_marks_lines_with_covered_instructions(tmp_path):
report = tmp_path / "target" / "site" / "jacoco" / "jacoco.xml"
report.parent.mkdir(parents=True)
report.write_text(
"""<?xml version="1.0"?>
<report name="demo">
<package name="demo">
<sourcefile name="Board.java">
<line nr="3" mi="0" ci="2" mb="0" cb="0"/>
<line nr="4" mi="4" ci="0" mb="1" cb="0"/>
<line nr="5" mi="0" ci="0" mb="0" cb="1"/>
</sourcefile>
</package>
</report>
""",
encoding="utf-8",
)
source = tmp_path / "src" / "demo" / "Board.java"
source.parent.mkdir(parents=True)
source.write_text("package demo;\nclass Board {}\n", encoding="utf-8")
assert covered_lines(tmp_path, source, "java") == {3, 5}
def test_go_profile_marks_hit_lines(tmp_path):
profile = tmp_path / "target" / "coverage" / "go" / "coverage.out"
profile.parent.mkdir(parents=True)
profile.write_text(
"mode: set\n"
"demo/widget.go:2.1,4.2 1 1\n"
"demo/widget.go:5.1,6.2 1 0\n",
encoding="utf-8",
)
source = tmp_path / "demo" / "widget.go"
source.parent.mkdir()
source.write_text("package demo\nfunc Run() {}\n", encoding="utf-8")
assert covered_lines(tmp_path, source, "go") == {2, 3, 4}
assert covered_lines(tmp_path, tmp_path / "other" / "missing.go", "go") is None
empty = tmp_path / "empty"
empty.mkdir()
assert covered_lines(empty, empty / "demo" / "widget.go", "go") is None
def test_lcov_marks_executed_lines(tmp_path):
report = tmp_path / "target" / "coverage" / "python" / "lcov.info"
report.parent.mkdir(parents=True)
report.write_text(
"SF:src/demo/app.py\nDA:1,3\nDA:2,0\nend_of_record\n",
encoding="utf-8",
)
source = tmp_path / "src" / "demo" / "app.py"
source.parent.mkdir(parents=True)
source.write_text("def place():\n return 1\n", encoding="utf-8")
assert covered_lines(tmp_path, source, "python") == {1}
assert covered_lines(tmp_path, source, "clojure") == {1}
+55
View File
@@ -0,0 +1,55 @@
from mutator.edn import dumps, keyword, loads
def test_round_trip_matches_the_snapshot_shape():
payload = {
"version": 1,
"tested-at": "2026-09-30T12:00:00-05:00",
"source": 'src/demo/say "hi".py',
"namespace": "demo.Board",
"outcomes": {
'["src/a.py","demo.Board","defn/place",1,2,">",">="]': keyword("killed"),
'["src/a.py","demo.Board","defn-/hide",3,4,"true","false"]': keyword("survived"),
},
"forms": [
{
"id": "defn/place",
"kind": "defn",
"file": "src/demo/Board.java",
"line": 4,
"end-line": 9,
"hash": "abc",
"killed": 1,
"survived": 0,
"uncovered": 2,
"sites": 3,
}
],
}
text = dumps(payload)
assert text.startswith("{")
assert ":namespace" in text
assert ":killed" in text
parsed = loads(text)
assert parsed["namespace"] == "demo.Board"
assert parsed["forms"][0]["id"] == "defn/place"
assert parsed["forms"][0]["end-line"] == 9
assert parsed["forms"][0]["killed"] == 1
assert set(parsed["outcomes"].values()) == {"killed", "survived"}
def test_empty_collections_and_nil():
assert loads("{:forms [] :outcomes {} :note nil}") == {
"forms": [],
"outcomes": {},
"note": None,
}
def test_atoms_cover_booleans_numbers_and_symbols():
assert loads("true") is True
assert loads("false") is False
assert loads("12") == 12
assert loads("-3") == -3
assert loads("1.50") == 1.5
assert loads("foo") == "foo"
+147
View File
@@ -0,0 +1,147 @@
from mutator.engine import mutate_file, restore_backups
from mutator.edn import loads
from mutator.metrics import snapshot_path
from mutator.runner import CommandResult
class FakeRunner:
def __init__(self):
self.verbose = False
self.calls = 0
def run(self, command, cwd, timeout):
self.calls += 1
text = (cwd / "src" / "demo.py").read_text(encoding="utf-8")
namespace: dict = {}
exec(text, namespace)
ok = namespace["add"](1, 2) == 3
return CommandResult(code=0 if ok else 1, timed_out=False, seconds=0.01, output="")
SOURCE = """def add(a, b):
if a > 0:
return a + b
return a + b
"""
def test_mutants_are_killed_or_kept_and_the_snapshot_is_differential(tmp_path):
path = tmp_path / "src" / "demo.py"
path.parent.mkdir()
path.write_text(SOURCE, encoding="utf-8")
original = path.read_bytes()
runner = FakeRunner()
first = mutate_file(
path,
tmp_path,
runner=runner,
covered_lines=None,
ignore_coverage=True,
mutate_all=True,
lines=None,
test_command="fake",
timeout_factor=10,
mutation_warning=50,
baselines={},
)
assert path.read_bytes() == original
assert first.baseline_failed is False
form = first.forms[0]
assert form.id == "defn/add"
assert form.namespace == "demo"
# `>` and the unexecuted `+` survive. The executed `+` is killed. `0` -> `1`
# still returns 3 for add(1, 2).
assert form.killed == 1
assert form.survived == 3
assert form.sites == 4
assert form.killed + form.survived + form.uncovered == form.sites
data = loads(snapshot_path(tmp_path, "demo").read_text(encoding="utf-8"))
assert data["namespace"] == "demo"
assert data["forms"][0]["id"] == "defn/add"
assert data["forms"][0]["killed"] == 1
assert data["forms"][0]["survived"] == 3
first_calls = runner.calls
second = mutate_file(
path,
tmp_path,
runner=runner,
covered_lines=None,
ignore_coverage=True,
mutate_all=False,
lines=None,
test_command="fake",
timeout_factor=10,
mutation_warning=50,
baselines={},
)
assert second.forms[0].killed == 1
assert second.forms[0].survived == 3
# Baseline plus the three survivors. The killed mutant stays in the snapshot.
assert runner.calls - first_calls == 4
def test_uncovered_sites_are_not_executed(tmp_path):
path = tmp_path / "src" / "demo.py"
path.parent.mkdir()
path.write_text(SOURCE, encoding="utf-8")
runner = FakeRunner()
result = mutate_file(
path,
tmp_path,
runner=runner,
covered_lines=set(),
ignore_coverage=False,
mutate_all=True,
lines=None,
test_command="fake",
timeout_factor=10,
mutation_warning=50,
baselines={},
)
assert runner.calls == 0
assert result.forms[0].uncovered == 4
assert result.forms[0].killed == 0
assert result.forms[0].sites == 4
def test_a_failed_baseline_does_not_rewrite_metrics(tmp_path):
path = tmp_path / "src" / "demo.py"
path.parent.mkdir()
path.write_text(SOURCE, encoding="utf-8")
class Red:
verbose = False
def run(self, command, cwd, timeout):
return CommandResult(code=1, timed_out=False, seconds=0.01, output="nope")
result = mutate_file(
path,
tmp_path,
runner=Red(),
covered_lines=None,
ignore_coverage=True,
mutate_all=True,
lines=None,
test_command="fake",
timeout_factor=10,
mutation_warning=50,
baselines={},
)
assert result.baseline_failed
assert not snapshot_path(tmp_path, "demo").exists()
def test_restore_backups_puts_an_interrupted_mutant_back(tmp_path):
source = tmp_path / "src" / "demo.py"
source.parent.mkdir()
source.write_text("original\n", encoding="utf-8")
backup = tmp_path / "target" / "mutator-backup" / "src" / "demo.py"
backup.parent.mkdir(parents=True)
backup.write_text("original\n", encoding="utf-8")
source.write_text("mutated\n", encoding="utf-8")
restored = restore_backups(tmp_path)
assert restored == [source]
assert source.read_text(encoding="utf-8") == "original\n"
assert not backup.exists()
+132
View File
@@ -0,0 +1,132 @@
from mutator.edn import dumps, loads
from mutator.metrics import load_history, snapshot_path, write_results
from mutator.model import FormResult
def _form(namespace, name, file_name, digest, killed=1, private=False):
form_id = f"defn-/{name}" if private else f"defn/{name}"
return FormResult(
id=form_id,
namespace=namespace,
name=name,
private=private,
file=file_name,
line=2,
end_line=4,
digest=digest,
killed=killed,
survived=0,
uncovered=1,
sites=2,
)
def _viewer_name(form_id):
"""The same split uml-viewer.application.overlay/form-name performs."""
if form_id.startswith("defn-/"):
return form_id[6:], True
if form_id.startswith("defn/"):
return form_id[5:], False
return None, False
def test_one_snapshot_per_namespace_preserves_the_other_file(tmp_path):
write_results(
tmp_path,
"a.go",
[_form("demo", "Run", "a.go", "hash-a")],
{},
)
write_results(
tmp_path,
"b.go",
[_form("demo", "Stop", "b.go", "hash-b", killed=4)],
{},
)
path = snapshot_path(tmp_path, "demo")
data = loads(path.read_text(encoding="utf-8"))
assert data["namespace"] == "demo"
names = []
for form in data["forms"]:
operation, private = _viewer_name(form["id"])
names.append((operation, private, form["killed"], form["uncovered"], form["sites"]))
assert ("Run", False, 1, 1, 2) in names
assert ("Stop", False, 4, 1, 2) in names
assert list(tmp_path.joinpath(".metrics", "mutate").rglob("*.edn")) == [path]
write_results(
tmp_path,
"a.go",
[_form("demo", "Run", "a.go", "hash-a", killed=9)],
{},
)
again = loads(path.read_text(encoding="utf-8"))
by_id = {form["id"]: form for form in again["forms"]}
assert by_id["defn/Run"]["killed"] == 9
assert by_id["defn/Stop"]["killed"] == 4
def test_private_form_id_and_history_round_trip(tmp_path):
outcome = '["hide.clj","demo.core","defn-/hide",8,12,"false","true"]'
write_results(
tmp_path,
"hide.clj",
[_form("demo.core", "hide", "hide.clj", "digest", private=True)],
{outcome: "killed"},
)
data = loads(snapshot_path(tmp_path, "demo.core").read_text(encoding="utf-8"))
assert _viewer_name(data["forms"][0]["id"]) == ("hide", True)
history = load_history(tmp_path, "hide.clj")
assert history.forms[("demo.core", "defn-/hide")].digest == "digest"
assert history.outcomes[outcome] == "killed"
def test_snapshot_paths_follow_the_namespace(tmp_path):
assert snapshot_path(tmp_path, "example.com/demo.Widget") == (
tmp_path / ".metrics" / "mutate" / "example.com" / "demo.Widget.edn"
)
assert snapshot_path(tmp_path, "crate::foo::Bar") == (
tmp_path / ".metrics" / "mutate" / "crate" / "foo" / "Bar.edn"
)
def test_a_non_canonical_snapshot_is_folded_into_the_namespace_file(tmp_path):
stale = tmp_path / ".metrics" / "mutate" / "legacy.edn"
stale.parent.mkdir(parents=True)
stale.write_text(
dumps(
{
"version": 1,
"namespace": "demo",
"source": "b.go",
"forms": [
{
"id": "defn/Stop",
"file": "b.go",
"line": 3,
"hash": "hash-b",
"killed": 4,
"survived": 0,
"uncovered": 1,
"sites": 2,
}
],
"outcomes": {},
}
)
+ "\n",
encoding="utf-8",
)
write_results(tmp_path, "a.go", [_form("demo", "Run", "a.go", "hash-a")], {})
assert not stale.exists()
data = loads(snapshot_path(tmp_path, "demo").read_text(encoding="utf-8"))
by_id = {form["id"]: form for form in data["forms"]}
assert by_id["defn/Stop"]["killed"] == 4
assert by_id["defn/Run"]["file"] == "a.go"
def test_dropping_a_file_removes_its_namespace_when_nothing_else_remains(tmp_path):
write_results(tmp_path, "a.go", [_form("demo", "Run", "a.go", "hash-a")], {})
write_results(tmp_path, "a.go", [], {})
assert not snapshot_path(tmp_path, "demo").exists()
+68
View File
@@ -0,0 +1,68 @@
from mutator.runner import _clojure_command, _python_command, nearest
def test_nearest_walks_up_to_the_marker(tmp_path):
root = tmp_path / "proj"
nested = root / "src" / "demo"
nested.mkdir(parents=True)
(root / "pyproject.toml").write_text("", encoding="utf-8")
source = nested / "app.py"
source.write_text("x = 1\n", encoding="utf-8")
assert nearest(source, "pyproject.toml", root) == root
assert nearest(nested, "pyproject.toml", root) == root
assert nearest(source, "missing.toml", root) is None
def test_clojure_commands(tmp_path):
bb = tmp_path / "bb-spec"
bb.mkdir()
(bb / "bb.edn").write_text('{:tasks {spec "spec"}}', encoding="utf-8")
assert _clojure_command(bb) == "bb spec --tag ~no-mutate"
bb_test = tmp_path / "bb-test"
bb_test.mkdir()
(bb_test / "bb.edn").write_text("{:tasks {test (clojure \"-M:test\")}}", encoding="utf-8")
assert _clojure_command(bb_test) == "bb test"
spec = tmp_path / "spec"
spec.mkdir()
(spec / "deps.edn").write_text(
"{:aliases {:spec {:extra-deps {speclj/speclj {}}}}}", encoding="utf-8"
)
assert _clojure_command(spec) == "clj -M:spec --tag ~no-mutate"
spec_only = tmp_path / "spec-only"
spec_only.mkdir()
(spec_only / "deps.edn").write_text("{:aliases {:spec {}}}", encoding="utf-8")
assert _clojure_command(spec_only) == "clj -M:spec"
plain = tmp_path / "plain"
plain.mkdir()
(plain / "deps.edn").write_text("{:deps {}}", encoding="utf-8")
assert _clojure_command(plain) == "clj -M:test"
def test_python_commands(tmp_path):
pytest_dir = tmp_path / "py"
pytest_dir.mkdir()
(pytest_dir / "pytest.ini").write_text("[pytest]\n", encoding="utf-8")
assert _python_command(pytest_dir).endswith("-m pytest")
unit = tmp_path / "unit"
unit.mkdir()
assert _python_command(unit).endswith("-m unittest discover")
cfg = tmp_path / "cfg"
cfg.mkdir()
(cfg / "setup.cfg").write_text("[tool:pytest]\n", encoding="utf-8")
assert _python_command(cfg).endswith("-m pytest")
project = tmp_path / "proj"
project.mkdir()
(project / "pyproject.toml").write_text("[project]\ndependencies=['pytest']\n", encoding="utf-8")
assert _python_command(project).endswith("-m pytest")
conf = tmp_path / "conf"
conf.mkdir()
(conf / "conftest.py").write_text("", encoding="utf-8")
assert _python_command(conf).endswith("-m pytest")
+165
View File
@@ -0,0 +1,165 @@
from pathlib import Path
from mutator.functions import form_id, is_private, project_functions, sites_in_file
def _sites(tmp_path: Path, relative: str, source: str):
path = tmp_path / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(source, encoding="utf-8")
return sites_in_file(source, path, tmp_path, relative)
def test_clojure_rules_follow_clj_mutate_and_mark_private_defns(tmp_path):
source = """(ns demo.core)
(defn place [x]
(if (> x 0)
(+ x 1)
0))
(defn- hide []
false)
"""
sites = _sites(tmp_path, "src/demo/core.clj", source)
described = {(site.form_id, site.original, site.mutant) for site in sites}
assert ("defn/place", "if", "if-not") in described
assert ("defn/place", ">", ">=") in described
assert ("defn/place", "+", "-") in described
assert ("defn/place", "0", "1") in described
assert ("defn/place", "1", "0") in described
assert ("defn-/hide", "false", "true") in described
assert {site.namespace for site in sites} == {"demo.core"}
functions = project_functions(source, tmp_path / "src/demo/core.clj", tmp_path)
assert form_id("hide", True) == "defn-/hide"
assert is_private("clojure", "hide", source, 6, 7)
assert {fn.namespace for fn in functions} == {"demo.core"}
def test_clojure_ignores_strings_comments_and_quotes(tmp_path):
source = """(ns demo.core)
(defn place []
(comment (+ 1 0))
(quote (+ 1 0))
(str "true + 1"))
"""
sites = _sites(tmp_path, "src/demo/core.clj", source)
assert sites == []
def test_java_rules_follow_mutate4java(tmp_path):
source = """package demo;
class Board {
int place(int x) {
if (x > 0 && ready)
return x + 1;
return !ok ? -x : 0;
}
private int hide() { return 1; }
}
"""
sites = _sites(tmp_path, "src/demo/Board.java", source)
public = [site for site in sites if site.form_id == "defn/place"]
private = [site for site in sites if site.form_id == "defn-/hide"]
assert ("+", "-") in {(site.original, site.mutant) for site in public}
assert ("/", "*") not in {(site.original, site.mutant) for site in public}
assert ("*", "/") not in {(site.original, site.mutant) for site in public}
assert (">", ">=") in {(site.original, site.mutant) for site in public}
assert ("&&", "||") in {(site.original, site.mutant) for site in public}
assert ("!", "") in {(site.original, site.mutant) for site in public}
assert ("-", "") in {(site.original, site.mutant) for site in public}
assert ("0", "1") in {(site.original, site.mutant) for site in public}
assert private == [] or {site.original for site in private} == {"1"}
assert {site.namespace for site in sites} == {"demo.Board"}
def test_java_swaps_division_both_ways(tmp_path):
source = """package demo;
class Math {
int place(int a, int b) { return a * b + a / b; }
}
"""
sites = _sites(tmp_path, "src/demo/Math.java", source)
pairs = {(site.original, site.mutant) for site in sites}
assert ("*", "/") in pairs
assert ("/", "*") in pairs
assert ("+", "-") in pairs
def test_go_matches_mutate4go_and_splits_receivers(tmp_path):
source = """package demo
func Run(a int, b int) int {
if a > 0 && b == 1 {
return a * b
}
return a / b
}
type Widget struct{}
func (w Widget) Run() bool { return true }
"""
sites = _sites(tmp_path, "demo.go", source)
free = [site for site in sites if site.namespace == "demo"]
method = [site for site in sites if site.namespace == "demo.Widget"]
pairs = {(site.original, site.mutant) for site in free}
assert ("*", "/") in pairs
assert ("/", "*") not in pairs
assert ("&&", "||") in pairs
assert ("==", "!=") in pairs
assert (">", ">=") in pairs
assert ("0", "1") in pairs
assert ("1", "0") in pairs
assert {site.form_id for site in method} == {"defn/Run"}
assert ("true", "false") in {(site.original, site.mutant) for site in method}
assert {site.form_id for site in free} == {"defn/Run"}
def test_go_unexported_function_is_private(tmp_path):
source = """package demo
func hide() bool { return false }
"""
sites = _sites(tmp_path, "demo.go", source)
assert sites[0].form_id == "defn-/hide"
def test_python_typescript_and_rust_use_the_java_decisions(tmp_path):
python = _sites(
tmp_path,
"src/demo/app.py",
"def place(x):\n if x > 0 and ready:\n return x + 1\n if not ok:\n return 0\n",
)
typescript = _sites(
tmp_path,
"src/demo/app.ts",
"export function place(x: number) {\n if (x > 0 && ready) return x + 1;\n return true === false;\n}\n",
)
rust = _sites(
tmp_path,
"src/lib.rs",
"fn place(x: i32) -> i32 {\n if x > 0 && ready { return x + 1; }\n if !ok { return -x; }\n 0\n}\n",
)
assert ("and", "or") in {(site.original, site.mutant) for site in python}
assert ("not", "") in {(site.original, site.mutant) for site in python}
assert ("True", "False") not in {(site.original, site.mutant) for site in python}
assert {site.namespace for site in python} == {"demo.app"}
assert ("===", "!==") in {(site.original, site.mutant) for site in typescript}
assert ("&&", "||") in {(site.original, site.mutant) for site in typescript}
assert {site.namespace for site in typescript} == {"demo.app"}
assert ("&&", "||") in {(site.original, site.mutant) for site in rust}
assert ("!", "") in {(site.original, site.mutant) for site in rust}
assert ("-", "") in {(site.original, site.mutant) for site in rust}
def test_python_private_and_class_namespace(tmp_path):
source = """class Board:
def place(self, x):
return x > 0
def _hide(self):
return False
"""
sites = _sites(tmp_path, "src/demo/board.py", source)
assert {site.form_id for site in sites if site.name == "place"} == {"defn/place"}
assert {site.form_id for site in sites if site.name == "_hide"} == {"defn-/_hide"}
assert {site.namespace for site in sites} == {"demo.board.Board"}
def test_string_operators_are_not_sites(tmp_path):
source = 'def place():\n return "x > 0 and true"\n'
assert _sites(tmp_path, "src/demo/app.py", source) == []