Skip to content

Commit 286514e

Browse files
committed
Expand mutation operators with built‑in name swaps and regex fuzzing
- Extend operator_name to flip more built‑ins (len↔sum, min↔max, all↔any, str↔repr, etc.) - Add operator_regex to mutate regex literals (quantifier swaps, {1,}↔+, \d↔[0-9], char‐class reversal) - Register operator_regex alongside other call mutations - Update tests to cover new regex and name‐swap behaviors
1 parent 51fd290 commit 286514e

File tree

3 files changed

+209
-9
lines changed

3 files changed

+209
-9
lines changed

mutmut/file_mutation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from mutmut.node_mutation import mutation_operators, OPERATORS_TYPE
1313

1414
NEVER_MUTATE_FUNCTION_NAMES = { "__getattribute__", "__setattr__", "__new__" }
15-
NEVER_MUTATE_FUNCTION_CALLS = { "len", "isinstance" }
15+
NEVER_MUTATE_FUNCTION_CALLS = { "isinstance" }
1616

1717
@dataclass
1818
class Mutation:

mutmut/node_mutation.py

Lines changed: 160 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
"""This module contains the mutations for indidvidual nodes, e.g. replacing a != b with a == b."""
1+
"""This module contains the mutations for individual nodes, e.g. replacing a != b with a == b."""
2+
import re
23
from typing import Any, Union
34
from collections.abc import Callable, Iterable, Sequence
45
import libcst as cst
@@ -107,11 +108,46 @@ def operator_keywords(
107108

108109

109110
def operator_name(node: cst.Name) -> Iterable[cst.CSTNode]:
110-
name_mappings = {
111+
name_mappings = {
111112
"True": "False",
112113
"False": "True",
113114
"deepcopy": "copy",
114-
# TODO: probably need to add a lot of things here... some builtins maybe, what more?
115+
"copy": "deepcopy",
116+
117+
# common aggregates
118+
"len": "sum",
119+
"sum": "len",
120+
"min": "max",
121+
"max": "min",
122+
123+
# boolean checks
124+
"all": "any",
125+
"any": "all",
126+
127+
# ordering
128+
"sorted": "reversed",
129+
"reversed": "sorted",
130+
131+
# numeric types
132+
"int": "float",
133+
"float": "int",
134+
135+
# byte types
136+
"bytes": "bytearray",
137+
"bytearray": "bytes",
138+
139+
# (optionally) mapping/filtering
140+
"map": "filter",
141+
"filter": "map",
142+
143+
# enums
144+
"Enum": "StrEnum",
145+
"StrEnum": "Enum",
146+
"IntEnum": "Enum",
147+
148+
# dict ↔ set might be fun… however, beware lol
149+
# "dict": "set",
150+
# "set": "dict",
115151
}
116152
if node.value in name_mappings:
117153
yield node.with_changes(value=name_mappings[node.value])
@@ -186,6 +222,123 @@ def operator_match(node: cst.Match) -> Iterable[cst.CSTNode]:
186222
for i in range(len(node.cases)):
187223
yield node.with_changes(cases=[*node.cases[:i], *node.cases[i+1:]])
188224

225+
def _mutate_regex(inner: str) -> list[str]:
226+
"""
227+
Generate ‘nasty’ variants of a regex body:
228+
- swap + ↔ * and ? ↔ *
229+
- turn `{0,1}` ↔ ?
230+
- turn `\d` ↔ `[0-9]` and `\w` ↔ `[A-Za-z0-9_]`
231+
- reverse the contents of any simple [...] class
232+
"""
233+
muts: list[str] = []
234+
# + <-> *
235+
if "+" in inner:
236+
muts.append(inner.replace("+", "*"))
237+
if "*" in inner:
238+
muts.append(inner.replace("*", "+"))
239+
# ? <-> *
240+
if "?" in inner:
241+
muts.append(inner.replace("?", "*"))
242+
if "*" in inner:
243+
muts.append(inner.replace("*", "?"))
244+
# {0,1} -> ? and ? -> {0,1}
245+
if re.search(r"\{0,1\}", inner):
246+
muts.append(re.sub(r"\{0,1\}", "?", inner))
247+
if "?" in inner:
248+
muts.append(re.sub(r"\?", "{0,1}", inner))
249+
# digit class ↔ shorthand
250+
if "\\d" in inner:
251+
muts.append(inner.replace("\\d", "[0-9]"))
252+
if "[0-9]" in inner:
253+
muts.append(inner.replace("[0-9]", "\\d"))
254+
# word class ↔ shorthand
255+
if "\\w" in inner:
256+
muts.append(inner.replace("\\w", "[A-Za-z0-9_]"))
257+
if "[A-Za-z0-9_]" in inner:
258+
muts.append(inner.replace("[A-Za-z0-9_]", "\\w"))
259+
# reverse simple character classes
260+
for mobj in re.finditer(r"\[([^\]]+)\]", inner):
261+
content = mobj.group(1)
262+
rev = content[::-1]
263+
orig = f"[{content}]"
264+
mutated = f"[{rev}]"
265+
muts.append(inner.replace(orig, mutated))
266+
# dedupe, preserve order
267+
return list(dict.fromkeys(muts))
268+
269+
270+
def operator_regex(node: cst.Call) -> Iterable[cst.CSTNode]:
271+
"""
272+
Look for calls like re.compile(r'…'), re.match, re.search, etc.,
273+
extract the first SimpleString arg, apply _mutate_regex, and yield
274+
one mutant per new pattern.
275+
"""
276+
if not m.matches(
277+
node,
278+
m.Call(
279+
func=m.Attribute(
280+
value=m.Name("re"),
281+
attr=m.MatchIfTrue(
282+
lambda t: t.value
283+
in ("compile", "match", "search", "fullmatch", "findall")
284+
),
285+
),
286+
args=[m.Arg(value=m.SimpleString())],
287+
),
288+
):
289+
return
290+
291+
arg = node.args[0]
292+
lit: cst.SimpleString = arg.value # type: ignore
293+
raw = lit.value # e.g. r'\d+\w*'
294+
# strip off leading r/R
295+
prefix = ""
296+
body = raw
297+
if raw[:2].lower() == "r'" or raw[:2].lower() == 'r"':
298+
prefix, body = raw[0], raw[1:]
299+
quote = body[0]
300+
inner = body[1:-1]
301+
302+
for mutated_inner in _mutate_regex(inner):
303+
new_raw = f"{prefix}{quote}{mutated_inner}{quote}"
304+
new_lit = lit.with_changes(value=new_raw)
305+
new_arg = arg.with_changes(value=new_lit)
306+
yield node.with_changes(args=[new_arg, *node.args[1:]])
307+
308+
309+
def operator_chr_ord(node: cst.Call) -> Iterable[cst.CSTNode]:
310+
"""Adjust chr/ord calls slightly instead of swapping names."""
311+
if isinstance(node.func, cst.Name) and node.args:
312+
name = node.func.value
313+
first_arg = node.args[0]
314+
if name == "chr":
315+
incr = cst.BinaryOperation(
316+
left=first_arg.value,
317+
operator=cst.Add(),
318+
right=cst.Integer("1"),
319+
)
320+
yield node.with_changes(args=[first_arg.with_changes(value=incr), *node.args[1:]])
321+
elif name == "ord":
322+
new_call = node
323+
yield cst.BinaryOperation(left=new_call, operator=cst.Add(), right=cst.Integer("1"))
324+
325+
326+
def operator_enum_attribute(node: cst.Attribute) -> Iterable[cst.CSTNode]:
327+
"""Swap common Enum base classes."""
328+
if not m.matches(node.value, m.Name("enum")):
329+
return
330+
331+
attr = node.attr
332+
if not isinstance(attr, cst.Name):
333+
return
334+
335+
if attr.value == "Enum":
336+
yield node.with_changes(attr=cst.Name("StrEnum"))
337+
yield node.with_changes(attr=cst.Name("IntEnum"))
338+
elif attr.value in {"StrEnum", "IntEnum"}:
339+
yield node.with_changes(attr=cst.Name("Enum"))
340+
341+
189342
# Operators that should be called on specific node types
190343
mutation_operators: OPERATORS_TYPE = [
191344
(cst.BaseNumber, operator_number),
@@ -197,6 +350,10 @@ def operator_match(node: cst.Match) -> Iterable[cst.CSTNode]:
197350
(cst.UnaryOperation, operator_remove_unary_ops),
198351
(cst.Call, operator_dict_arguments),
199352
(cst.Call, operator_arg_removal),
353+
(cst.Call, operator_chr_ord),
354+
(cst.Call, operator_regex),
355+
(cst.Call, operator_chr_ord),
356+
(cst.Attribute, operator_enum_attribute),
200357
(cst.Lambda, operator_lambda),
201358
(cst.CSTNode, operator_keywords),
202359
(cst.CSTNode, operator_swap_op),
@@ -212,5 +369,3 @@ def _simple_mutation_mapping(
212369
if mutated_node_type:
213370
yield mutated_node_type()
214371

215-
216-
# TODO: detect regexes and mutate them in nasty ways? Maybe mutate all strings as if they are regexes

tests/test_mutation.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
get_diff_for_mutant,
1010
orig_function_and_class_names_from_key,
1111
run_forced_fail_test,
12-
Config,
1312
MutmutProgrammaticFailException,
1413
CatchOutput,
1514
)
@@ -154,6 +153,12 @@ def mutated_module(source: str) -> str:
154153
('import foo', []),
155154
('isinstance(a, b)', []),
156155
('len(a)', []),
156+
('len(a)', ['sum(a)', 'len(None)']),
157+
('sum(a)', ['len(a)', 'sum(None)']),
158+
('chr(65)', ['chr(65 + 1)', 'chr(None)']),
159+
("ord('a')", ["ord('a') + 1", 'ord(None)']),
160+
('enum.Enum', ['enum.StrEnum', 'enum.IntEnum']),
161+
('enum.StrEnum', ['enum.Enum']),
157162
('deepcopy(obj)', ['copy(obj)', 'deepcopy(None)']),
158163
]
159164
)
@@ -485,7 +490,7 @@ def test_run_forced_fail_test_with_failing_test(_start, _stop, _dump_output, cap
485490
print(f"out: {out}")
486491
print(f"err: {err}")
487492
assert 'done' in out
488-
assert os.environ['MUTANT_UNDER_TEST'] is ''
493+
assert os.environ['MUTANT_UNDER_TEST'] == ''
489494

490495

491496
# Negate the effects of CatchOutput because it does not play nicely with capfd in GitHub Actions
@@ -514,7 +519,7 @@ def test_run_forced_fail_test_with_all_tests_passing(_start, _stop, _dump_output
514519
with pytest.raises(SystemExit) as error:
515520
run_forced_fail_test(runner)
516521

517-
assert error.value.code is 1
522+
assert error.value.code == 1
518523
out, err = capfd.readouterr()
519524
assert 'FAILED: Unable to force test failures' in out
520525

@@ -667,3 +672,43 @@ def add(self, *args, **kwargs):
667672
xǁAdderǁadd__mutmut_orig.__name__ = 'xǁAdderǁadd'
668673
669674
print(Adder(1).add(2))"""
675+
676+
@pytest.mark.parametrize("original, want_patterns", [
677+
(
678+
"re.compile(r'\\d+')",
679+
[
680+
"re.compile(r'\\d*')", # + → *
681+
"re.compile(r'[0-9]+')", # \d → [0-9]
682+
],
683+
),
684+
(
685+
"re.search(r'[abc]+')",
686+
[
687+
"re.search(r'[abc]*')", # + → *
688+
"re.search(r'[cba]+')", # [abc] → [cba]
689+
],
690+
),
691+
(
692+
"re.match(r'\\w{1,}')",
693+
[
694+
"re.match(r'[A-Za-z0-9_]{1,}')" # \w → [A-Za-z0-9_]
695+
],
696+
),
697+
(
698+
"re.match(r'foo?')",
699+
[
700+
"re.match(r'foo*')", # ? → *
701+
"re.match(r'foo{0,1}')", # ? → {0,1}
702+
],
703+
),
704+
(
705+
"re.search(r'bar{0,1}')",
706+
[
707+
"re.search(r'bar?')", # {0,1} → ?
708+
],
709+
),
710+
])
711+
def test_regex_mutations_loose(original, want_patterns):
712+
mutants = mutants_for_source(original)
713+
for want in want_patterns:
714+
assert want in mutants, f"expected {want!r} in {mutants}"

0 commit comments

Comments
 (0)