"""
universalmutator mutation testing parser.
universalmutator is a language-agnostic mutation tool that works by applying
regex-based mutations to source files. It outputs results as plain text files.
Output files:
killed.txt - One mutant filename per line (mutants detected by tests)
not-killed.txt - One mutant filename per line (mutants NOT detected)
notkilled.txt - Alternative name for not-killed.txt
Mutant filenames typically follow the pattern:
<original_file>.mutant.<number>.<mutation_type>
or
<original_file>_mutant_<number>
Examples:
src/main.py.mutant.1.AOR
src/utils.py.mutant.2.ROR
src/main.py.mutant.3.CRP
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from curate_ipsum.models import FileMutationStats
LOG = logging.getLogger("parsers.universalmutator")
# Pattern to extract original file from mutant filename
# Matches: <filepath>.mutant.<number>[.<operator>]
_MUTANT_FILENAME_PATTERN = re.compile(r"^(.+?)\.mutant\.(\d+)(?:\.(.+))?$")
# Alternative pattern: <filepath>_mutant_<number>
_ALT_MUTANT_FILENAME_PATTERN = re.compile(r"^(.+?)_mutant_(\d+)$")
[docs]
def find_universalmutator_results(working_directory: str) -> Path | None:
"""
Locate universalmutator result files.
Searches for killed.txt / not-killed.txt in common locations.
Args:
working_directory: Starting directory to search
Returns:
Path to the directory containing result files, or None
"""
cwd = Path(working_directory)
# Direct location
if (cwd / "killed.txt").exists() or (cwd / "not-killed.txt").exists():
return cwd
if (cwd / "killed.txt").exists() or (cwd / "notkilled.txt").exists():
return cwd
# Check subdirectories
for subdir_name in ("results", "mutation_results", "mutants", ".universalmutator"):
subdir = cwd / subdir_name
if subdir.is_dir():
if (subdir / "killed.txt").exists() or (subdir / "not-killed.txt").exists():
return subdir
if (subdir / "killed.txt").exists() or (subdir / "notkilled.txt").exists():
return subdir
return None
def _extract_source_file(mutant_filename: str) -> str:
"""
Extract the original source file path from a mutant filename.
Args:
mutant_filename: The mutant filename (e.g., "src/main.py.mutant.1.AOR")
Returns:
Original source file path (e.g., "src/main.py")
"""
# Strip any directory prefix (mutant files may include paths)
name = mutant_filename.strip()
# Try standard pattern: file.py.mutant.N[.OP]
match = _MUTANT_FILENAME_PATTERN.match(name)
if match:
return match.group(1)
# Try alternative pattern: file.py_mutant_N
match = _ALT_MUTANT_FILENAME_PATTERN.match(name)
if match:
return match.group(1)
# Fallback: return the filename itself (can't determine source)
return name
def _read_mutant_list(file_path: Path) -> list[str]:
"""
Read a list of mutant filenames from a text file.
Each line is one mutant filename. Empty lines and comment lines
(starting with #) are skipped.
Args:
file_path: Path to the text file
Returns:
List of mutant filenames
"""
if not file_path.exists():
return []
mutants = []
with file_path.open("r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
mutants.append(line)
return mutants
[docs]
def parse_universalmutator_output(
working_directory: str,
report_path: str | None = None,
) -> tuple[int, int, int, int, float, list[FileMutationStats]]:
"""
Parse universalmutator results from killed.txt / not-killed.txt.
Main entry point matching the unified parser interface (D-009).
Args:
working_directory: Project directory
report_path: Optional explicit path to results directory
Returns:
Tuple of (total, killed, survived, no_coverage, score, by_file)
Raises:
FileNotFoundError: If result files not found
"""
if report_path:
results_dir = Path(report_path)
if results_dir.is_file():
results_dir = results_dir.parent
else:
results_dir = find_universalmutator_results(working_directory)
if results_dir is None or not results_dir.exists():
raise FileNotFoundError(
f"universalmutator results not found. Run universalmutator first. "
f"Searched for killed.txt / not-killed.txt in: {working_directory}"
)
# Read killed mutants
killed_file = results_dir / "killed.txt"
killed_mutants = _read_mutant_list(killed_file)
# Read survived mutants (try both naming conventions)
not_killed_file = results_dir / "not-killed.txt"
survived_mutants = _read_mutant_list(not_killed_file)
if not survived_mutants:
# Try alternative name
alt_file = results_dir / "notkilled.txt"
survived_mutants = _read_mutant_list(alt_file)
if not killed_mutants and not survived_mutants:
# No results found at all
raise FileNotFoundError(
f"No universalmutator results found. Expected killed.txt and/or not-killed.txt in: {results_dir}"
)
LOG.debug(
"Read %d killed, %d survived mutants",
len(killed_mutants),
len(survived_mutants),
)
# Group by source file
by_file_map: dict[str, dict[str, int]] = {} # file -> {killed, survived}
for mutant_name in killed_mutants:
source = _extract_source_file(mutant_name)
if source not in by_file_map:
by_file_map[source] = {"killed": 0, "survived": 0}
by_file_map[source]["killed"] += 1
for mutant_name in survived_mutants:
source = _extract_source_file(mutant_name)
if source not in by_file_map:
by_file_map[source] = {"killed": 0, "survived": 0}
by_file_map[source]["survived"] += 1
# Build per-file stats
file_stats: list[FileMutationStats] = []
total_killed = 0
total_survived = 0
for file_path in sorted(by_file_map.keys()):
counts = by_file_map[file_path]
killed = counts["killed"]
survived = counts["survived"]
total = killed + survived
total_killed += killed
total_survived += survived
denominator = killed + survived
score = killed / denominator if denominator > 0 else 0.0
file_stats.append(
FileMutationStats(
filePath=file_path,
totalMutants=total,
killed=killed,
survived=survived,
noCoverage=0, # universalmutator doesn't track coverage
mutationScore=score,
)
)
total = total_killed + total_survived
denominator = total_killed + total_survived
overall_score = total_killed / denominator if denominator > 0 else 0.0
LOG.info(
"universalmutator results: %d mutants, %d killed, %d survived, score=%.2f",
total,
total_killed,
total_survived,
overall_score,
)
return (
total,
total_killed,
total_survived,
0, # universalmutator doesn't distinguish no_coverage
overall_score,
file_stats,
)