From c617656746bfb16d36f28fcd8fd5c6d1f967ec93 Mon Sep 17 00:00:00 2001 From: HoldenHrabak Date: Fri, 17 Apr 2026 14:12:23 -0700 Subject: [PATCH 1/3] Issue #23: allow comments and blank lines in rule files parseRules() already silently ignored lines starting with '#' but only when they contained no leading whitespace and the line wasn't exactly '\n'. Indented comments (' # note') and whitespace-only blank lines produced spurious 'DOES NOT MATCH EXPECTED FORMAT' warnings. Rewrite the branch so that comment/blank detection runs on the stripped line, and verify rule-precedence is preserved: '#include ==> DO_NOT_MUTATE' (c_like.rules) and '# ==> SKIP_MUTATING_REST' (python.rules) still parse as rules because the ' ==> ' check still runs first. --- README.md | 24 ++++ tests/test_rule_comments.py | 144 ++++++++++++++++++++++++ universalmutator/mutator.py | 36 +++--- universalmutator/static/universal.rules | 10 ++ 4 files changed, 199 insertions(+), 15 deletions(-) create mode 100644 tests/test_rule_comments.py diff --git a/README.md b/README.md index 6c5cab7..80e96d9 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,30 @@ Sometimes the mutated code needs to be built with a more complicated command tha Working with something like maven is very similar, except you can probably edit the complicated build/clean stuff to just a 'mvn test' or similar. +RULE FILES +========== + +Rules live in `.rules` files (see `universalmutator/static/` for the built-in +set). Each rule has the form ` ==> `, where `` +is a Python regular expression (or a [Comby](https://github.com/comby-tools/comby) +template in `--comby` mode). Two special right-hand sides are recognised: +`DO_NOT_MUTATE` marks matching lines as not-to-be-mutated, and +`SKIP_MUTATING_REST` tells the mutator to skip everything on a line past the +match (useful for in-source comments). + +Lines beginning with `#` (optionally indented) are treated as comments, and +blank lines are ignored. You can use this to document groups of related rules +or temporarily disable a rule without deleting it: + +``` +# Arithmetic operator swaps +\+ ==> - +\+ ==> * + +# Temporarily disabled while investigating false positives: +# && ==> || +``` + CURRENTLY SUPPORTED LANGUAGES ============================= diff --git a/tests/test_rule_comments.py b/tests/test_rule_comments.py new file mode 100644 index 0000000..16a74f6 --- /dev/null +++ b/tests/test_rule_comments.py @@ -0,0 +1,144 @@ +"""Tests for comment and blank-line handling in rule files (issue #23). + +These tests verify parseRules() correctly: + * treats lines beginning with '#' (optionally indented) as comments + * skips blank and whitespace-only lines without warning + * continues to parse existing rules whose LHS starts with '#' + (e.g. '#include ==> DO_NOT_MUTATE', '# ==> SKIP_MUTATING_REST') + * still warns on genuinely malformed rule lines +""" +from __future__ import print_function + +import io +import os +import sys +import tempfile +import unittest + +from universalmutator.mutator import parseRules + + +class TestRuleComments(unittest.TestCase): + + def _parse(self, rule_text): + """Write rule_text to a temp .rules file, call parseRules, return + (rules, ignoreRules, skipRules, captured_stdout).""" + fd, path = tempfile.mkstemp(suffix=".rules") + os.close(fd) + try: + with open(path, "w") as f: + f.write(rule_text) + buf = io.StringIO() + orig_stdout = sys.stdout + sys.stdout = buf + try: + rules, ignoreRules, skipRules = parseRules([path]) + finally: + sys.stdout = orig_stdout + return rules, ignoreRules, skipRules, buf.getvalue() + finally: + os.unlink(path) + + def assertNoMalformedWarning(self, output): + self.assertNotIn( + "DOES NOT MATCH EXPECTED FORMAT", output, + "parseRules printed a malformed-rule warning but should not have:\n" + + output) + + # --- Comments at start of line --------------------------------------- + + def test_plain_hash_comment_is_ignored(self): + rules, _, _, out = self._parse( + "# a plain comment\n" + "\\+ ==> -\n" + ) + self.assertEqual(len(rules), 1) + self.assertNoMalformedWarning(out) + + def test_indented_hash_comment_is_ignored(self): + rules, _, _, out = self._parse( + " # indented comment with 4 spaces\n" + "\t# tab-indented comment\n" + "\\+ ==> -\n" + ) + self.assertEqual(len(rules), 1) + self.assertNoMalformedWarning(out) + + def test_multiple_comments_do_not_produce_rules(self): + rules, ignoreRules, skipRules, out = self._parse( + "# comment one\n" + "# comment two\n" + "# comment three\n" + ) + self.assertEqual(len(rules), 0) + self.assertEqual(len(ignoreRules), 0) + self.assertEqual(len(skipRules), 0) + self.assertNoMalformedWarning(out) + + # --- Blank lines ------------------------------------------------------ + + def test_empty_blank_line_is_ignored(self): + rules, _, _, out = self._parse( + "\\+ ==> -\n" + "\n" + "\\* ==> /\n" + ) + self.assertEqual(len(rules), 2) + self.assertNoMalformedWarning(out) + + def test_whitespace_only_line_is_ignored(self): + rules, _, _, out = self._parse( + "\\+ ==> -\n" + " \n" + "\t\t\n" + "\\* ==> /\n" + ) + self.assertEqual(len(rules), 2) + self.assertNoMalformedWarning(out) + + # --- Backward compatibility: LHS starting with '#' -------------------- + + def test_hash_include_rule_still_parses_as_ignore(self): + """c_like.rules contains '#include ==> DO_NOT_MUTATE' — must not + be mistaken for a comment.""" + _, ignoreRules, _, out = self._parse("#include ==> DO_NOT_MUTATE\n") + self.assertEqual(len(ignoreRules), 1) + self.assertNoMalformedWarning(out) + + def test_bare_hash_skip_rule_still_parses_as_skip(self): + """python.rules contains '# ==> SKIP_MUTATING_REST' — must not + be mistaken for a comment.""" + _, _, skipRules, out = self._parse("# ==> SKIP_MUTATING_REST\n") + self.assertEqual(len(skipRules), 1) + self.assertNoMalformedWarning(out) + + # --- Mixed content ---------------------------------------------------- + + def test_header_comment_block_and_rules(self): + """Realistic case: documentation header plus rules.""" + rules, ignoreRules, skipRules, out = self._parse( + "# ============================================\n" + "# Universal rules for arithmetic operator swaps\n" + "# ============================================\n" + "\n" + "# Addition to other operators\n" + "\\+ ==> -\n" + "\\+ ==> *\n" + "\n" + "# Skip rest of line after Python '#' comment\n" + "# ==> SKIP_MUTATING_REST\n" + ) + self.assertEqual(len(rules), 2) + self.assertEqual(len(skipRules), 1) + self.assertNoMalformedWarning(out) + + # --- Warnings still fire for bad input -------------------------------- + + def test_malformed_line_still_warns(self): + """A line with no '==>' and not a comment should still warn.""" + _, _, _, out = self._parse("this line is not a rule\n") + self.assertIn("DOES NOT MATCH EXPECTED FORMAT", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/universalmutator/mutator.py b/universalmutator/mutator.py index 1791d61..7a4bcf1 100644 --- a/universalmutator/mutator.py +++ b/universalmutator/mutator.py @@ -37,22 +37,28 @@ def parseRules(ruleFiles, comby=False): for (r, ruleSource) in rulesText: ruleLineNo += 1 - if r == "\n": - continue - if " ==> " not in r: - if " ==>" in r: - s = r.split(" ==>") - else: - if r[0] == "#": # Don't warn about comments - continue - print("*" * 60) - print("WARNING:") - print("RULE:", r, "FROM", ruleSource) - print("DOES NOT MATCH EXPECTED FORMAT, AND SO WAS IGNORED") - print("*" * 60) - continue # Allow blank lines and comments, just ignore lines without a transformation - else: + + # Rule lines (containing " ==> ") are parsed as rules. This check comes + # first so that existing rules whose LHS legitimately starts with '#' + # (e.g. "#include ==> DO_NOT_MUTATE" in c_like.rules, or the bare + # "# ==> SKIP_MUTATING_REST" in python.rules) continue to work. + if " ==> " in r: s = r.split(" ==> ") + elif " ==>" in r: + s = r.split(" ==>") + else: + # Not a rule line. Allow full-line comments (starting with '#', + # optionally preceded by whitespace) and blank/whitespace-only + # lines; warn on anything else. + stripped = r.strip() + if stripped == "" or stripped.startswith("#"): + continue + print("*" * 60) + print("WARNING:") + print("RULE:", r, "FROM", ruleSource) + print("DOES NOT MATCH EXPECTED FORMAT, AND SO WAS IGNORED") + print("*" * 60) + continue if comby: lhs = s[0] diff --git a/universalmutator/static/universal.rules b/universalmutator/static/universal.rules index e3b6d78..f483e62 100644 --- a/universalmutator/static/universal.rules +++ b/universalmutator/static/universal.rules @@ -1,5 +1,15 @@ +# ============================================================================ +# Universal rule set: language-agnostic mutations applied to every source file. +# Rules have the form: ==> +# Two special right-hand sides: +# DO_NOT_MUTATE - do not mutate any line matching +# SKIP_MUTATING_REST - skip mutating the rest of the line past +# Lines beginning with '#' (optionally indented) and blank lines are ignored. +# ============================================================================ + DO_NOT_MUTATE ==> DO_NOT_MUTATE +# Addition: swap with other arithmetic operators \+ ==> - \+ ==> * \+ ==> / From 2d055c57b23520aebebee3af4888044709d919d8 Mon Sep 17 00:00:00 2001 From: ClassProg Date: Tue, 21 Apr 2026 10:19:08 -0700 Subject: [PATCH 2/3] Update test_rule_comments.py Added tests for comby mode to ensure new comment style is also recognized properly --- tests/test_rule_comments.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_rule_comments.py b/tests/test_rule_comments.py index 16a74f6..7f663f1 100644 --- a/tests/test_rule_comments.py +++ b/tests/test_rule_comments.py @@ -14,9 +14,13 @@ import sys import tempfile import unittest +import shutil +import subprocess from universalmutator.mutator import parseRules +#confirm comby installation +HAS_COMBY = shutil.which("comby") is not None class TestRuleComments(unittest.TestCase): @@ -139,6 +143,37 @@ def test_malformed_line_still_warns(self): _, _, _, out = self._parse("this line is not a rule\n") self.assertIn("DOES NOT MATCH EXPECTED FORMAT", out) +"""COMBY MODE TESTS""" +class TestCombyIntegration(unittest.TestCase): + """Integration tests that only run if 'comby' is installed.""" + + @unittest.skipUnless(HAS_COMBY, "Comby binary not found in PATH") + def test_comby_execution_with_comments(self): + """Ensure the tool doesn't crash when passing commented rules to Comby.""" + # Setup dummy source and rules + fd_src, src_path = tempfile.mkstemp(suffix=".py") + fd_rule, rule_path = tempfile.mkstemp(suffix=".rules") + os.close(fd_src) + os.close(fd_rule) + + try: + with open(src_path, "w") as f: + f.write("x = a + b") + with open(rule_path, "w") as f: + f.write("# A comment to ignore\n:[1] + :[2] ==> :[1] - :[2]\n") + + # Execute via subprocess to test the full CLI path + cmd = ["universalmutator", src_path, "--rules", rule_path, "--comby", "--dump"] + result = subprocess.run(cmd, capture_output=True, text=True) + + # 0 means the comments didn't cause a Comby syntax error + self.assertEqual(result.returncode, 0) + # Ensure the valid rule was still applied + self.assertIn("-", result.stdout) + finally: + if os.path.exists(src_path): os.unlink(src_path) + if os.path.exists(rule_path): os.unlink(rule_path) + if __name__ == "__main__": unittest.main() From da642109c03f305f4ceea3600138f406c0409581 Mon Sep 17 00:00:00 2001 From: ClassProg Date: Tue, 21 Apr 2026 22:18:03 -0700 Subject: [PATCH 3/3] Update test_rule_comments.py Fixed an issue where the new comby test would not pass due to the improper parsing of sys.argv arguments. This fix may need to be altered again in conjunction with the update to argparse, but restores test functionality for current version. Also adjusted cmd to use the path to the current Python interpreter --- tests/test_rule_comments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_rule_comments.py b/tests/test_rule_comments.py index 7f663f1..f5261e9 100644 --- a/tests/test_rule_comments.py +++ b/tests/test_rule_comments.py @@ -163,7 +163,7 @@ def test_comby_execution_with_comments(self): f.write("# A comment to ignore\n:[1] + :[2] ==> :[1] - :[2]\n") # Execute via subprocess to test the full CLI path - cmd = ["universalmutator", src_path, "--rules", rule_path, "--comby", "--dump"] + cmd = [sys.executable, "-m", "universalmutator.genmutants", src_path, f"--rules={rule_path}", "--comby", "--dump"] result = subprocess.run(cmd, capture_output=True, text=True) # 0 means the comments didn't cause a Comby syntax error