From a073c350cd7982fff2a01c472ef5fe053cc232fd Mon Sep 17 00:00:00 2001 From: Junhyung Lee Date: Sun, 2 Aug 2026 17:52:10 +0900 Subject: [PATCH] feat: mutate ternary conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #196. A ternary is a branch that nothing here mutated. Branch coverage does not see an uncovered arm either, so an untested arm read as a pass twice over. `grep -rn "IfExp\|ternary\|if_exp"` over src/, tests/ and docs/ returned nothing before this. `operator_if_exp` yields two mutants per ternary, neutralising the condition in each direction: a if b else c -> a if (b) and False else c a if (b) or True else c On the parentheses, which #478's review asked to drop: they are load-bearing for the `and False` half. `and` binds tighter than a top-level `or`, so an unparenthesised `b or c` becomes `b or (c and False)`, which still takes the true branch whenever `b` is truthy. Over the 16 assignments of a `a if b or c else d`: (b or c) and False differs from the original on 6 of 16 b or c and False differs on 2 of 16, and only when b is falsy `or True` genuinely does not need them — parenthesised and not are identical on all 16 — but wrapping both keeps one rule rather than two, and costs nothing. test_function_with_annotation shifts because its fixture contains a ternary: the two new mutants take numbers 1 and 2 and push the arithmetic and index mutants to 3-5. The three original assertions are kept, renumbered, and the two new mutants are asserted alongside them, so the test still shows nothing was lost. Measured on 4f12080, `pytest tests/`: 12 failed / 345 passed before and after, with an identical FAILED set. (tests/utils/test_safe_setproctitle.py is flaky here regardless of this change — 1 pass in 5 runs both with and without it.) ruff check, ruff format --check and mypy pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/mutmut/mutation/mutators.py | 22 ++++++++++++++++++++++ tests/mutation/test_mutation.py | 10 +++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/mutmut/mutation/mutators.py b/src/mutmut/mutation/mutators.py index 0045d245..40b074d9 100644 --- a/src/mutmut/mutation/mutators.py +++ b/src/mutmut/mutation/mutators.py @@ -248,6 +248,27 @@ def operator_match(node: cst.Match) -> Iterable[cst.CSTNode]: yield node.with_changes(cases=[*node.cases[:i], *node.cases[i + 1 :]]) +def operator_if_exp(node: cst.IfExp) -> Iterable[cst.IfExp]: + """Force a ternary down each branch by neutralising its condition. + + A ternary's two arms are a branch that nothing else here mutates, and + branch coverage does not see an uncovered arm either, so an untested arm + reads as a pass twice over. + + The condition is parenthesised before `and False` / `or True` is appended. + Without the parentheses `and` binds tighter than a top-level `or`, so + `b or c` would become `b or (c and False)` -- which still takes the true + branch whenever `b` is truthy, i.e. an almost-always-surviving mutant that + tests cannot kill. `or True` happens not to need them, but wrapping both + keeps one rule instead of two. + """ + parenthesised = node.test.with_changes(lpar=[cst.LeftParen()], rpar=[cst.RightParen()]) + for operator, literal in ((cst.And(), "False"), (cst.Or(), "True")): + yield node.with_changes( + test=cst.BooleanOperation(left=parenthesised, operator=operator, right=cst.Name(literal)) + ) + + # Operators that should be called on specific node types mutation_operators: OPERATORS_TYPE = [ (cst.BaseNumber, operator_number), @@ -265,6 +286,7 @@ def operator_match(node: cst.Match) -> Iterable[cst.CSTNode]: (cst.CSTNode, operator_keywords), (cst.CSTNode, operator_swap_op), (cst.Match, operator_match), + (cst.IfExp, operator_if_exp), ] diff --git a/tests/mutation/test_mutation.py b/tests/mutation/test_mutation.py index 6c602d98..b5525986 100644 --- a/tests/mutation/test_mutation.py +++ b/tests/mutation/test_mutation.py @@ -429,9 +429,13 @@ def test_function_with_annotation(): print(mutated_code) expected_defs = [ - "def x_capitalize__mutmut_1(s : str):\n return s[0].title() - s[1:] if s else s", - "def x_capitalize__mutmut_2(s : str):\n return s[1].title() + s[1:] if s else s", - "def x_capitalize__mutmut_3(s : str):\n return s[0].title() + s[2:] if s else s", + # The ternary's condition is mutated first; `operator_if_exp` yields + # these two and shifts the arithmetic/index mutants down by two. + "def x_capitalize__mutmut_1(s : str):\n return s[0].title() + s[1:] if (s) and False else s", + "def x_capitalize__mutmut_2(s : str):\n return s[0].title() + s[1:] if (s) or True else s", + "def x_capitalize__mutmut_3(s : str):\n return s[0].title() - s[1:] if s else s", + "def x_capitalize__mutmut_4(s : str):\n return s[1].title() + s[1:] if s else s", + "def x_capitalize__mutmut_5(s : str):\n return s[0].title() + s[2:] if s else s", ] for expected in expected_defs: