Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions slither/tools/mutator/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,25 @@ def parse_target_selectors(selector_str: str) -> set[int]:
###################################################################################


# Exit code returned when the campaign completes successfully but at least one
# mutant escaped the test suite. Distinct from 1 (used by argparse / setup
# errors) so CI scripts can tell the two apart.
EXIT_CODE_UNCAUGHT_MUTANTS = 2


def _exit_for_campaign_results(total_mutants: int, caught_mutants: int) -> None:
"""Exit non-zero with a summary if any compiled mutants were uncaught."""
uncaught = total_mutants - caught_mutants
if uncaught > 0:
logger.info(
red(
f"{uncaught} uncaught mutant(s) out of {total_mutants} that compiled "
f"({caught_mutants} caught)"
)
)
sys.exit(EXIT_CODE_UNCAUGHT_MUTANTS)


def main() -> None:
args = parse_args()

Expand All @@ -213,6 +232,10 @@ def main() -> None:
mutators_to_run: list[str] | None = args.mutators_to_run
comprehensive_flag: bool | None = args.comprehensive

# accumulators across all files for the campaign-wide summary / exit code
campaign_total_mutants = 0
campaign_caught_mutants = 0

logger.info(blue(f"Starting mutation campaign in {args.codebase}"))

if paths_to_ignore:
Expand Down Expand Up @@ -463,6 +486,9 @@ def main() -> None:
else:
logger.info(magenta("Zero Tweak mutants analyzed\n"))

campaign_total_mutants += sum(total_mutant_counts)
campaign_caught_mutants += sum(caught_mutant_counts)

# Reset mutant counts before moving on to the next file
total_mutant_counts[0] = 0
total_mutant_counts[1] = 0
Expand All @@ -486,5 +512,7 @@ def main() -> None:
blue(f"Finished mutation testing assessment of '{args.codebase}' in {elapsed_string}\n")
)

_exit_for_campaign_results(campaign_total_mutants, campaign_caught_mutants)


# endregion
31 changes: 30 additions & 1 deletion tests/tools/mutator/test_mutator.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@

import pytest
from slither import Slither
from slither.tools.mutator.__main__ import _get_mutators, main, parse_target_selectors
from slither.tools.mutator.__main__ import (
EXIT_CODE_UNCAUGHT_MUTANTS,
_exit_for_campaign_results,
_get_mutators,
main,
parse_target_selectors,
)
from slither.tools.mutator.utils.testing_generated_mutant import run_test_cmd
from slither.tools.mutator.utils.file_handling import get_sol_file_list, backup_source_file
from slither.utils.function import get_function_id
Expand Down Expand Up @@ -255,6 +261,29 @@ def test_should_mutate_function_no_match(solc_binary_path):
assert mutator.should_mutate_function(func) is False


def test_exit_for_campaign_results_all_caught_does_not_exit():
"""All compiled mutants caught: helper returns without raising SystemExit."""
_exit_for_campaign_results(total_mutants=5, caught_mutants=5)


def test_exit_for_campaign_results_no_mutants_does_not_exit():
"""No mutants compiled at all (e.g. interface-only run): exit 0."""
_exit_for_campaign_results(total_mutants=0, caught_mutants=0)


def test_exit_for_campaign_results_uncaught_exits_with_code(caplog):
"""At least one uncaught mutant: exit with EXIT_CODE_UNCAUGHT_MUTANTS and log a summary."""
with pytest.raises(SystemExit) as excinfo:
_exit_for_campaign_results(total_mutants=10, caught_mutants=7)
assert excinfo.value.code == EXIT_CODE_UNCAUGHT_MUTANTS
assert excinfo.value.code != 1
# Surface the actual numbers so CI logs are actionable.
assert any(
"3 uncaught" in record.message and "10 that compiled" in record.message
for record in caplog.records
)


def test_should_mutate_function_includes_modifier(solc_binary_path):
"""Modifier used by target function should be mutated"""
solc_path = solc_binary_path("0.8.15")
Expand Down