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
4 changes: 4 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ left off.
To work with the results, use `mutmut browse` where you can see the mutants,
retest them when you've updated your tests.

When a mutant that removes code, like ``x = foo(a)`` -> ``x = None``, survives,
`browse` hides the other mutants inside that code, because they are shadowed by
it. Press `s` to show them.

You can also write a mutant to disk from the `browse` interface, or via
`mutmut apply <mutant>`. You should **REALLY** have the file you mutate under
source code control and committed before you apply a mutant!
Expand Down
6 changes: 6 additions & 0 deletions src/mutmut/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,12 @@ def create_mutants_for_file(filename: Path, output_path: Path) -> FileMutationRe
merged[key] = data.exit_code_by_key.get(key)
data.exit_code_by_key = merged
data.hash_by_function_name = dict(hash_by_function_name)
data.source_span_by_key = {
get_mutant_name(filename, name): span for name, span in mutated_file.source_span_by_mutant_name.items()
}
data.removed_span_by_key = {
get_mutant_name(filename, name): span for name, span in mutated_file.removed_span_by_mutant_name.items()
}
data.save()

MutantLineSpans(path=filename, span_by_function_name=mutated_file.line_span_by_function_name).save()
Expand Down
52 changes: 52 additions & 0 deletions src/mutmut/mutation/data.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
from collections.abc import Mapping
from collections.abc import Sequence
from dataclasses import astuple
from dataclasses import dataclass
from itertools import islice
from pathlib import Path
Expand All @@ -14,6 +15,23 @@ class LineSpan:
end: int


@dataclass(frozen=True)
class SourceSpan:
"""A range of code in a source file, as libcst reports it.

Lines are 1-based and columns 0-based. The end column is exclusive."""

start_line: int
start_column: int
end_line: int
end_column: int

def contains(self, other: "SourceSpan") -> bool:
starts_before = (self.start_line, self.start_column) <= (other.start_line, other.start_column)
ends_after = (other.end_line, other.end_column) <= (self.end_line, self.end_column)
return starts_before and ends_after


class MutantLineSpans:
"""Index of the lines each generated function occupies in a mutated file.

Expand Down Expand Up @@ -103,6 +121,10 @@ def __init__(self, *, path: Path | str) -> None:
self.hash_by_function_name: dict[str, str] = {}
self.durations_by_key: dict[str, float] = {}
self.type_check_error_by_key: dict[str, str | None] = {}
#: The code in the source file that each mutant changes
self.source_span_by_key: dict[str, SourceSpan] = {}
#: The code that mutants like `a = b` -> `a = None` remove, see `shadowed_by_key`
self.removed_span_by_key: dict[str, SourceSpan] = {}

def load(self) -> None:
try:
Expand All @@ -116,6 +138,8 @@ def load(self) -> None:
self.type_check_error_by_key = meta.pop("type_check_error_by_key", {})
self.durations_by_key = meta.pop("durations_by_key")
self.estimated_time_of_tests_by_mutant = meta.pop("estimated_durations_by_key")
self.source_span_by_key = {k: SourceSpan(*v) for k, v in meta.pop("source_span_by_key", {}).items()}
self.removed_span_by_key = {k: SourceSpan(*v) for k, v in meta.pop("removed_span_by_key", {}).items()}
assert not meta, f"Meta file {self.meta_path} constains unexpected keys: {set(meta.keys())}"

def save(self) -> None:
Expand All @@ -127,7 +151,35 @@ def save(self) -> None:
"type_check_error_by_key": self.type_check_error_by_key,
"durations_by_key": self.durations_by_key,
"estimated_durations_by_key": self.estimated_time_of_tests_by_mutant,
"source_span_by_key": {k: astuple(v) for k, v in self.source_span_by_key.items()},
"removed_span_by_key": {k: astuple(v) for k, v in self.removed_span_by_key.items()},
},
f,
indent=4,
)

def shadowed_by_key(self) -> dict[str, str]:
"""Find the mutants that are shadowed by a surviving mutant that removes their code.

If the tests do not notice that `x = foo(a + 1)` became `x = None`, the mutants inside
`foo(a + 1)` are not worth looking at on their own. Fixing the tests for the mutant that
removes the code is likely to deal with them too.

:return: The outermost surviving mutant that shadows each shadowed mutant."""
# exit code 0 means the mutant survived
shadowing_keys = [key for key in self.removed_span_by_key if self.exit_code_by_key.get(key) == 0]

def outermost_first(key: str) -> tuple[int, int, int, int]:
# the removed spans that contain a mutant are nested in each other
span = self.removed_span_by_key[key]
return span.start_line, span.start_column, -span.end_line, -span.end_column

shadowing_keys.sort(key=outermost_first)

shadowed_by_key: dict[str, str] = {}
for key, span in self.source_span_by_key.items():
for shadowing_key in shadowing_keys:
if key != shadowing_key and self.removed_span_by_key[shadowing_key].contains(span):
shadowed_by_key[key] = shadowing_key
break
return shadowed_by_key
67 changes: 65 additions & 2 deletions src/mutmut/mutation/file_mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from collections.abc import Mapping
from collections.abc import Sequence
from dataclasses import dataclass
from dataclasses import field
from dataclasses import replace
from pathlib import Path
from typing import Union
Expand All @@ -21,7 +22,9 @@

from mutmut.configuration import config
from mutmut.mutation.data import LineSpan
from mutmut.mutation.data import SourceSpan
from mutmut.mutation.mutators import OPERATORS_TYPE
from mutmut.mutation.mutators import code_removing_operators
from mutmut.mutation.mutators import mutation_operators
from mutmut.mutation.pragma_handling import IgnoredCode
from mutmut.mutation.pragma_handling import get_ignored_lines
Expand Down Expand Up @@ -88,6 +91,10 @@ class Mutation:
original_node: cst.CSTNode
mutated_node: cst.CSTNode
contained_by_top_level_function: cst.FunctionDef | None
#: The code this mutation changes
source_span: SourceSpan | None = None
#: The code this mutation removes, if it is made by one of the `code_removing_operators`
removed_span: SourceSpan | None = None


@dataclass(frozen=True)
Expand All @@ -100,6 +107,10 @@ class MutatedFile:
line_span_by_function_name: Mapping[str, LineSpan]
#: Hash of each mutated function, used to detect which functions changed between runs
hash_by_function_name: Mapping[str, str]
#: The code in the source file that each mutant changes
source_span_by_mutant_name: Mapping[str, SourceSpan] = field(default_factory=dict)
#: The code that each mutant made by one of the `code_removing_operators` removes
removed_span_by_mutant_name: Mapping[str, SourceSpan] = field(default_factory=dict)


def mutate_file_contents(
Expand Down Expand Up @@ -241,9 +252,45 @@ def _create_mutations(self, node: cst.CSTNode) -> None:
original_node=node,
mutated_node=mutated_node,
contained_by_top_level_function=self.get_metadata(OuterFunctionProvider, node, None), # type: ignore
source_span=self._source_span(node),
removed_span=self._removed_span(node, mutated_node)
if operator in code_removing_operators
else None,
)
self.mutations.append(mutation)

def _source_span(self, node: cst.CSTNode) -> SourceSpan | None:
position = self.get_metadata(PositionProvider, node, None)
if position is None:
return None
return SourceSpan(position.start.line, position.start.column, position.end.line, position.end.column)

def _removed_span(self, node: cst.CSTNode, mutated_node: cst.CSTNode) -> SourceSpan | None:
"""The span of the children of `node` that are not in `mutated_node` anymore.

The operators build the mutated node with `node.with_changes(...)`, so the children
that are kept are the very same objects.

Replacing a plain name or literal, like `x = False` -> `x = None`, does not count as
removing code. Such mutants often survive (None is falsy too), and would then hide
the mutants worth looking at, like `x = False` -> `x = True`."""
kept = {id(child) for child in mutated_node.children}
removed = [child for child in node.children if id(child) not in kept]
if all(_is_name_or_literal(child.value if isinstance(child, cst.Arg) else child) for child in removed):
return None

spans: list[SourceSpan] = []
for child in removed:
span = self._source_span(child)
if span is None:
return None
spans.append(span)

return SourceSpan(
*min((span.start_line, span.start_column) for span in spans),
*max((span.end_line, span.end_column) for span in spans),
)

def _removes_ignored_code(self, node: cst.CSTNode, mutated_node: cst.CSTNode) -> bool:
"""Does this mutation delete code that we were told not to mutate?

Expand Down Expand Up @@ -361,6 +408,10 @@ def _is_enum_class(node: cst.ClassDef) -> bool:
return any(isinstance(base, cst.Name) and base.value in ENUM_BASE_NAMES for base in bases)


def _is_name_or_literal(node: cst.CSTNode) -> bool:
return isinstance(node, cst.Name | cst.BaseNumber | cst.SimpleString | cst.Ellipsis)


def _walk(node: cst.CSTNode) -> Iterator[cst.CSTNode]:
"""Yield `node` and every node below it."""
stack = [node]
Expand Down Expand Up @@ -397,6 +448,16 @@ def combine_mutations_to_source(
# copy start of the module (in particular __future__ imports)
result: list[MODULE_STATEMENT] = get_statements_until_func_or_class(module.body)
mutation_names: list[str] = []
source_span_by_mutant_name: dict[str, SourceSpan] = {}
removed_span_by_mutant_name: dict[str, SourceSpan] = {}

def add_mutant_names(mutant_names: Sequence[str], mutants: Sequence[Mutation]) -> None:
mutation_names.extend(mutant_names)
for mutant_name, mutant in zip(mutant_names, mutants, strict=True):
if mutant.source_span is not None:
source_span_by_mutant_name[mutant_name] = mutant.source_span
if mutant.removed_span is not None:
removed_span_by_mutant_name[mutant_name] = mutant.removed_span

# statements we still need to potentially mutate and add to the result
remaining_statements = module.body[len(result) :]
Expand All @@ -422,7 +483,7 @@ def combine_mutations_to_source(
result.extend(empty_dict_nodes)
result.extend(nodes)
result.extend(mutant_dict_assignment_nodes)
mutation_names.extend(mutant_names)
add_mutant_names(mutant_names, func_mutants)
elif isinstance(statement, cst.ClassDef):
cls = statement
if not isinstance(cls.body, cst.IndentedBlock):
Expand All @@ -444,7 +505,7 @@ def combine_mutations_to_source(
pre_class_nodes.extend(empty_dict_nodes)
mutated_body.extend(nodes)
post_class_nodes.extend(mutant_dict_assignment_nodes)
mutation_names.extend(mutant_names)
add_mutant_names(mutant_names, method_mutants)

result.extend(pre_class_nodes)
result.append(cls.with_changes(body=cls.body.with_changes(body=mutated_body)))
Expand All @@ -459,6 +520,8 @@ def combine_mutations_to_source(
mutant_names=mutation_names,
line_span_by_function_name=line_spans,
hash_by_function_name={},
source_span_by_mutant_name=source_span_by_mutant_name,
removed_span_by_mutant_name=removed_span_by_mutant_name,
)


Expand Down
4 changes: 4 additions & 0 deletions src/mutmut/mutation/mutators.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,10 @@ def operator_if_exp(node: cst.IfExp) -> Iterable[cst.IfExp]:
(cst.IfExp, operator_if_exp),
]

# Operators that remove code, like `a = b` -> `a = None`. If such a mutant survives, it shadows
# the other mutants inside the code it removes.
code_removing_operators = {operator_assignment, operator_arg_removal, operator_lambda, operator_match}


def _simple_mutation_mapping(
node: cst.CSTNode, mapping: dict[type[cst.CSTNode], type[cst.CSTNode]]
Expand Down
52 changes: 44 additions & 8 deletions src/mutmut/ui/browse.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class ResultBrowser(App[None]):
("m", "retest_module()", "Retest module"),
("a", "apply_mutant()", "Apply mutant to disk"),
("t", "view_tests()", "View tests for mutant"),
("s", "toggle_shadowed()", "Show/hide shadowed mutants"),
]

columns = [
Expand All @@ -53,6 +54,9 @@ class ResultBrowser(App[None]):
cursor_type = "row"
source_file_mutation_data_and_stat_by_path: dict[str, tuple[SourceFileMutationData, Stat]] = {}
path_by_name: dict[str, Path] = {}
shadowed_by_key: dict[str, str] = {}
show_shadowed = False
selected_path: str | None = None
diff_load_lock = Lock()

def compose(self) -> Iterable[Any]:
Expand Down Expand Up @@ -82,6 +86,7 @@ def on_mount(self) -> None:
def read_data(self) -> None:
self.source_file_mutation_data_and_stat_by_path = {}
self.path_by_name: dict[str, Path] = {}
self.shadowed_by_key = {}

for p in walk_mutatable_files():
source_file_mutation_data = SourceFileMutationData(path=p)
Expand All @@ -91,6 +96,7 @@ def read_data(self) -> None:
self.source_file_mutation_data_and_stat_by_path[str(p)] = source_file_mutation_data, stat
for name in source_file_mutation_data.exit_code_by_key:
self.path_by_name[name] = p
self.shadowed_by_key.update(source_file_mutation_data.shadowed_by_key())

def populate_files_table(self) -> None:
# noinspection PyTypeChecker
Expand All @@ -107,18 +113,28 @@ def populate_files_table(self) -> None:

files_table.move_cursor(row=selected_row)

def populate_mutants_table(self) -> None:
# noinspection PyTypeChecker
mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment]
mutants_table.clear()
if self.selected_path is None:
return

source_file_mutation_data, stat = self.source_file_mutation_data_and_stat_by_path[self.selected_path]
for k, v in source_file_mutation_data.exit_code_by_key.items():
status = status_by_exit_code[v]
if self.is_listed(status) and (self.show_shadowed or k not in self.shadowed_by_key):
mutants_table.add_row(k, emoji_by_status[status], key=k)

def is_listed(self, status: str) -> bool:
return status not in ("killed", "caught by type check") or show_killed

def on_data_table_row_highlighted(self, event: Any) -> None:
if not event.row_key or not event.row_key.value:
return
if event.data_table.id == "files":
# noinspection PyTypeChecker
mutants_table: DataTable[Any] = self.query_one("#mutants") # type: ignore[assignment]
mutants_table.clear()
source_file_mutation_data, stat = self.source_file_mutation_data_and_stat_by_path[event.row_key.value]
for k, v in source_file_mutation_data.exit_code_by_key.items():
status = status_by_exit_code[v]
if status not in ("killed", "caught by type check") or show_killed:
mutants_table.add_row(k, emoji_by_status[status], key=k)
self.selected_path = event.row_key.value
self.populate_mutants_table()
else:
assert event.data_table.id == "mutants"
# noinspection PyTypeChecker
Expand Down Expand Up @@ -165,6 +181,22 @@ def on_data_table_row_highlighted(self, event: Any) -> None:
description = "Not checked in the last mutmut run."
case _:
description = f"Unknown status ({exit_code=}, {status=})"

shadowed_count = sum(
1
for k, shadowing_key in self.shadowed_by_key.items()
if shadowing_key == mutant_name
and self.is_listed(status_by_exit_code[source_file_mutation_data.exit_code_by_key[k]])
)
if shadowed_count:
mutants = "mutant" if shadowed_count == 1 else "mutants"
description += (
f"\n It shadows {shadowed_count} {mutants} in the code it removes (press s to show/hide them)."
)
if mutant_name in self.shadowed_by_key:
description += (
f"\n Shadowed by {self.shadowed_by_key[mutant_name]}, which removes this code and survived."
)
description_view.update(f"\n {description}\n")

diff_view: Static = self.query_one("#diff_view") # type: ignore[assignment]
Expand Down Expand Up @@ -251,4 +283,8 @@ def action_view_tests(self) -> None:
if name is not None:
self.view_tests(name)

def action_toggle_shadowed(self) -> None:
self.show_shadowed = not self.show_shadowed
self.populate_mutants_table()

ResultBrowser().run()
Loading