Source code for curate_ipsum.parsers.stryker_parser

"""
Stryker mutation testing report parser.

Stryker is a mutation testing framework primarily for JavaScript/TypeScript.
This parser handles Stryker's JSON report format.

Report locations searched (in order):
1. Explicit reportPath argument
2. reports/mutation/mutation.json
3. reports/stryker-report.json
"""

from __future__ import annotations

import json
import logging
import os
from collections.abc import Iterable
from pathlib import Path

from curate_ipsum.models import FileMutationStats

LOG = logging.getLogger("parsers.stryker")

DEFAULT_STRYKER_REPORT = os.environ.get("MUTATION_TOOL_STRYKER_REPORT", "reports/mutation/mutation.json")


def _compute_mutation_score(killed: int, survived: int, no_coverage: int) -> float:
    """
    Compute mutation score as killed / (killed + survived).

    No-coverage mutants are excluded from the denominator as they
    represent gaps in test coverage rather than mutation detection.
    """
    denominator = killed + survived
    if denominator == 0:
        return 0.0
    return killed / float(denominator)


def _collect_mutants(data: dict) -> dict[str, list[dict]]:
    """
    Extract mutants from Stryker report, handling different report formats.

    Stryker has evolved its report format over versions. This handles:
    - files.{path}.mutants format (newer)
    - mutantResults array format (older)
    """
    files: dict[str, list[dict]] = {}

    if "files" in data and isinstance(data["files"], dict):
        # Newer format: files grouped by path
        for path, file_info in data["files"].items():
            mutants = file_info.get("mutants", [])
            if isinstance(mutants, list):
                files[path] = mutants
    elif "mutantResults" in data and isinstance(data["mutantResults"], list):
        # Older format: flat list of mutants
        files["<all>"] = data["mutantResults"]

    return files


def _count_mutants(mutants: Iterable[dict]) -> tuple[int, int, int]:
    """
    Count mutants by status.

    Returns: (killed, survived, no_coverage)
    """
    killed = survived = no_coverage = 0

    for mutant in mutants:
        status = str(mutant.get("status", "")).lower()
        if status == "killed":
            killed += 1
        elif status == "survived":
            survived += 1
        elif status in ("no coverage", "nocoverage", "survivednocoverage"):
            no_coverage += 1
        # Other statuses (timeout, error, etc.) are not counted

    return killed, survived, no_coverage


[docs] def find_stryker_report( working_directory: str, report_path: str | None = None, ) -> Path | None: """ Locate the Stryker report file. Args: working_directory: Project directory report_path: Optional explicit path to report Returns: Path to report file, or None if not found """ cwd_path = Path(working_directory) candidate_paths: list[Path] = [] # Explicit path takes priority if report_path: explicit_path = Path(report_path) candidate_paths.append(explicit_path) if not explicit_path.is_absolute(): candidate_paths.append(cwd_path / explicit_path) # Default locations if DEFAULT_STRYKER_REPORT: candidate_paths.append(cwd_path / DEFAULT_STRYKER_REPORT) candidate_paths.extend( [ cwd_path / "reports" / "mutation" / "mutation.json", cwd_path / "reports" / "stryker-report.json", cwd_path / "stryker-report.json", ] ) return next((p for p in candidate_paths if p.exists()), None)
[docs] def parse_stryker_report(report_path: Path) -> dict: """ Parse a Stryker JSON report file. 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"Stryker report not found: {report_path}") with report_path.open("r", encoding="utf-8") as handle: data = json.load(handle) if not isinstance(data, dict): raise ValueError(f"Invalid Stryker report format: expected dict, got {type(data)}") return data
[docs] def parse_stryker_output( report_path: str | None, working_directory: str, ) -> tuple[int, int, int, int, float, list[FileMutationStats]]: """ Parse Stryker mutation testing output. This is the main entry point for Stryker parsing. Args: report_path: Optional explicit path to report working_directory: Project directory Returns: Tuple of (total_mutants, killed, survived, no_coverage, score, by_file) Raises: FileNotFoundError: If report not found ValueError: If report format is invalid """ report_file = find_stryker_report(working_directory, report_path) if report_file is None: raise FileNotFoundError(f"Stryker report not found in expected locations. Searched in: {working_directory}") LOG.debug("Parsing Stryker report: %s", report_file) data = parse_stryker_report(report_file) files = _collect_mutants(data) by_file: list[FileMutationStats] = [] total_killed = total_survived = total_no_coverage = total_mutants = 0 for file_path, mutants in sorted(files.items()): killed, survived, no_cov = _count_mutants(mutants) total = len(mutants) total_mutants += total total_killed += killed total_survived += survived total_no_coverage += no_cov score = _compute_mutation_score(killed, survived, no_cov) by_file.append( FileMutationStats( filePath=str(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)): reported_score = float(score_from_report) # Stryker sometimes reports as percentage (0-100) if reported_score > 1: reported_score /= 100.0 mutation_score = reported_score else: mutation_score = _compute_mutation_score(total_killed, total_survived, total_no_coverage) LOG.info( "Stryker results: %d mutants, %d killed, %d survived, score=%.2f", total_mutants, total_killed, total_survived, mutation_score, ) return ( total_mutants, total_killed, total_survived, total_no_coverage, mutation_score, by_file, )