"""
Poodle (poodle-test) mutation testing parser.
Poodle is a Python mutation testing tool that outputs JSON reports following
the mutation-testing-report-schema (same schema used by Stryker).
Report format:
{
"schemaVersion": "1",
"thresholds": {"high": 80, "low": 60},
"files": {
"src/module.py": {
"language": "python",
"source": "...",
"mutants": [
{
"id": "1",
"mutatorName": "ConditionalsBoundary",
"replacement": ">=",
"location": {
"start": {"line": 10, "column": 5},
"end": {"line": 10, "column": 6}
},
"status": "Killed",
"description": "..."
},
...
]
},
...
}
}
Status values (mutation-testing-report-schema):
- Killed: Mutant was detected by tests
- Survived: Mutant was not detected
- NoCoverage: No tests cover this mutant
- CompileError: Mutant caused a compilation error
- RuntimeError: Mutant caused a runtime error
- Timeout: Test execution timed out
- Ignored: Mutant was ignored (pragma, config)
- Pending: Mutant has not been tested yet
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from curate_ipsum.models import FileMutationStats
LOG = logging.getLogger("parsers.poodle")
# Status classification for mutation-testing-report-schema
_KILLED_STATUSES = {"killed"}
_SURVIVED_STATUSES = {"survived"}
_NO_COVERAGE_STATUSES = {"nocoverage", "no coverage"}
_IGNORED_STATUSES = {
"compileerror",
"compile error",
"runtimeerror",
"runtime error",
"timeout",
"ignored",
"pending",
}
[docs]
def find_poodle_report(working_directory: str) -> Path | None:
"""
Locate the poodle mutation testing report.
Searches for common poodle report locations:
1. mutation-report.json (poodle default)
2. poodle-report.json
3. reports/mutation/mutation.json
4. .poodle-report.json
Args:
working_directory: Starting directory to search
Returns:
Path to report file, or None if not found
"""
cwd = Path(working_directory)
candidates = [
cwd / "mutation-report.json",
cwd / "poodle-report.json",
cwd / ".poodle-report.json",
cwd / "reports" / "mutation-report.json",
cwd / "reports" / "mutation" / "mutation.json",
cwd / "mutation-testing-report.json",
]
for candidate in candidates:
if candidate.exists():
LOG.debug("Found poodle report: %s", candidate)
return candidate
# Look for any JSON file matching mutation-testing-report schema
for json_file in cwd.glob("*.json"):
try:
with json_file.open("r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict) and "files" in data and "schemaVersion" in data:
# Check if it looks like a mutation-testing-report
files_val = data["files"]
if isinstance(files_val, dict) and any(
isinstance(v, dict) and "mutants" in v for v in files_val.values()
):
LOG.debug("Found poodle-compatible report: %s", json_file)
return json_file
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
continue
return None
def _normalize_status(status: str) -> str:
"""Normalize status string for comparison."""
return status.lower().replace("_", "").replace(" ", "")
def _count_mutants_by_status(
mutants: list[dict],
) -> tuple[int, int, int]:
"""
Count mutants by status category.
Returns: (killed, survived, no_coverage)
"""
killed = 0
survived = 0
no_coverage = 0
for mutant in mutants:
status = _normalize_status(str(mutant.get("status", "")))
if status in _KILLED_STATUSES:
killed += 1
elif status in _SURVIVED_STATUSES:
survived += 1
elif status in _NO_COVERAGE_STATUSES:
no_coverage += 1
# Other statuses (compileerror, timeout, ignored, pending) are
# counted toward total but not in the score denominator
return killed, survived, no_coverage
[docs]
def parse_poodle_report(report_path: Path) -> dict:
"""
Parse a poodle JSON report file.
The report follows the mutation-testing-report-schema, which is the
same schema used by Stryker. This parser handles poodle-specific
nuances (Python file paths, poodle status values).
Args:
report_path: Path to the report file
Returns:
Parsed JSON data
Raises:
FileNotFoundError: If report doesn't exist
ValueError: If report is not valid JSON or wrong format
"""
if not report_path.exists():
raise FileNotFoundError(f"Poodle report not found: {report_path}")
with report_path.open("r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError(f"Invalid poodle report format: expected dict, got {type(data).__name__}")
return data
[docs]
def parse_poodle_output(
working_directory: str,
report_path: str | None = None,
) -> tuple[int, int, int, int, float, list[FileMutationStats]]:
"""
Parse poodle mutation testing output.
Main entry point matching the unified parser interface (D-009).
Args:
working_directory: Project directory
report_path: Optional explicit path to report file
Returns:
Tuple of (total, killed, survived, no_coverage, score, by_file)
Raises:
FileNotFoundError: If report not found
ValueError: If report format is invalid
"""
if report_path:
report_file = Path(report_path)
if not report_file.is_absolute():
report_file_abs = Path(working_directory) / report_file
if report_file_abs.exists():
report_file = report_file_abs
else:
report_file = find_poodle_report(working_directory)
if report_file is None or not report_file.exists():
raise FileNotFoundError(f"Poodle report not found. Run 'poodle' first. Searched in: {working_directory}")
LOG.debug("Parsing poodle report: %s", report_file)
data = parse_poodle_report(report_file)
# Extract files section
files = data.get("files", {})
if not isinstance(files, dict):
LOG.warning("No 'files' section in poodle report")
return (0, 0, 0, 0, 0.0, [])
by_file: list[FileMutationStats] = []
total_killed = 0
total_survived = 0
total_no_coverage = 0
total_mutants = 0
for file_path, file_info in sorted(files.items()):
if not isinstance(file_info, dict):
continue
mutants = file_info.get("mutants", [])
if not isinstance(mutants, list):
continue
killed, survived, no_cov = _count_mutants_by_status(mutants)
total = len(mutants)
total_mutants += total
total_killed += killed
total_survived += survived
total_no_coverage += no_cov
denominator = killed + survived
score = killed / denominator if denominator > 0 else 0.0
by_file.append(
FileMutationStats(
filePath=file_path,
totalMutants=total,
killed=killed,
survived=survived,
noCoverage=no_cov,
mutationScore=score,
)
)
# Use report's score if available, otherwise compute
score_from_report = data.get("mutationScore")
if isinstance(score_from_report, (int, float)):
overall_score = float(score_from_report)
# Normalize percentage to decimal
if overall_score > 1:
overall_score /= 100.0
else:
denominator = total_killed + total_survived
overall_score = total_killed / denominator if denominator > 0 else 0.0
LOG.info(
"Poodle results: %d mutants, %d killed, %d survived, score=%.2f",
total_mutants,
total_killed,
total_survived,
overall_score,
)
return (
total_mutants,
total_killed,
total_survived,
total_no_coverage,
overall_score,
by_file,
)