From c11c497423aba9213089a5ea0a0a6de19f820508 Mon Sep 17 00:00:00 2001 From: by-ae <25704492+by-ae@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:16:53 +0000 Subject: [PATCH 1/2] Don't produce equivalent case mutants inside string escape sequences Co-authored-by: Cursor Agent --- src/mutmut/mutation/mutators.py | 37 ++++++++++++++++++++++++++++----- tests/e2e/test_e2e_my_lib.py | 1 - tests/mutation/test_mutation.py | 2 +- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/mutmut/mutation/mutators.py b/src/mutmut/mutation/mutators.py index d6fa5252..f4ff29c9 100644 --- a/src/mutmut/mutation/mutators.py +++ b/src/mutmut/mutation/mutators.py @@ -19,8 +19,17 @@ ] ] -# pattern to match (nearly) all chars in a string that are not part of an escape sequence -NON_ESCAPE_SEQUENCE = re.compile(r"((? Iterable[cst.BaseNumber]: @@ -53,13 +62,31 @@ def operator_string(node: cst.BaseString) -> Iterable[cst.BaseString]: # that mutation is meaningless for return + # For raw strings (r"..." or R"..."), escape sequences are literal, so case changes are NOT equivalent + is_raw = "r" in prefix.lower() + supported_str_mutations: list[Callable[[str], str]] = [ lambda x: "XX" + x + "XX", - # do not modify escape sequences, as this could break python syntax - lambda x: NON_ESCAPE_SEQUENCE.sub(lambda match: match.group(1).lower(), x), - lambda x: NON_ESCAPE_SEQUENCE.sub(lambda match: match.group(1).upper(), x), ] + if is_raw: + # Raw strings: use original regex (all non-backslash chars) + old_pattern = re.compile(r"((? str: r'a: str = "Fo\N{ghost} \U11223344"', [ r'a: str = "XXFo\N{ghost} \U11223344XX"', - r'a: str = "FO\N{GHOST} \U11223344"', + r'a: str = "FO\N{ghost} \U11223344"', r'a: str = "fo\N{ghost} \U11223344"', "a: str = None", ], From 8ca9f497c1eed19be4fc03272378fe7ec8d29842 Mon Sep 17 00:00:00 2001 From: by-ae <25704492+by-ae@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:17:21 +0000 Subject: [PATCH 2/2] Add tests for case mutations around escape sequences Co-authored-by: Cursor Agent --- tests/mutation/test_mutation.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/mutation/test_mutation.py b/tests/mutation/test_mutation.py index 283cbff2..d5ca2ea9 100644 --- a/tests/mutation/test_mutation.py +++ b/tests/mutation/test_mutation.py @@ -239,6 +239,14 @@ def mutated_module(source: str) -> str: "a: str = None", ], ), + # Escape sequences with hex digits should not produce equivalent case-flip mutants + (r'"\u00a0"', [r'"XX\u00a0XX"']), + (r'"\U0001f600"', [r'"XX\U0001f600XX"']), + (r'"\x7f"', [r'"XX\x7fXX"']), + (r'"\N{GREEK CAPITAL LETTER DELTA}"', [r'"XX\N{GREEK CAPITAL LETTER DELTA}XX"']), + (r'"Hello\u00a0World"', [r'"XXHello\u00a0WorldXX"', r'"hello\u00a0world"', r'"HELLO\u00a0WORLD"']), + # Raw strings: escapes are literal, so case changes are NOT equivalent + (r'r"\u00a0"', [r'r"XX\u00a0XX"', r'r"\u00A0"']), ("lambda: 0", ["lambda: 1", "lambda: None"]), ("1 in (1, 2)", ["2 in (1, 2)", "1 not in (1, 2)", "1 in (2, 2)", "1 in (1, 3)"]), ("1+1", ["2+1", "1 - 1", "1+2"]),