mirror of
https://github.com/unclebob/mutator.git
synced 2026-11-06 18:23:50 +00:00
Add a multi-language mutation tester that writes .metrics/mutate for uml-viewer.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.metrics/
|
||||
target/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 "$@"
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Mutation testing for the languages crapper scores."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from mutator.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 = ""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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) == []
|
||||
Reference in New Issue
Block a user