From c57f03879a08d2afe8c7e044e86c80bb164afd30 Mon Sep 17 00:00:00 2001 From: "Robert C. Martin" Date: Sat, 3 Oct 2026 18:03:53 -0500 Subject: [PATCH] Bring every CRAP score under 10 except the option parser. Rendering, mutation-site rules, coverage ranking, and the per-language test command are each a small function. parse_args stays the option switch. --- src/mutator/coverage.py | 99 +++++++++++++++++++++++++++-------------- src/mutator/edn.py | 67 +++++++++++++++++++++------- src/mutator/runner.py | 85 ++++++++++++++++++++++++----------- src/mutator/sites.py | 91 +++++++++++++++++++++++++++---------- 4 files changed, 242 insertions(+), 100 deletions(-) diff --git a/src/mutator/coverage.py b/src/mutator/coverage.py index 586f865..a4e5d33 100644 --- a/src/mutator/coverage.py +++ b/src/mutator/coverage.py @@ -33,30 +33,82 @@ def _parts(path: str) -> tuple[str, ...]: return tuple(part for part in path.split("/") if part) -def _rank(key_parts: tuple[str, ...], sources: list[tuple[str, ...]]) -> tuple[int, int] | None: - """How well one report key names one of the source paths. +def _as_suffix(longer: tuple[str, ...], shorter: tuple[str, ...]) -> int | None: + """How many extra parts `longer` adds in front of `shorter`, when it ends with it.""" - Rank `(0, extra)` is a key that ends with the source and may add a prefix. - Rank `(1, missing)` is a shorter key that the source path ends with. The - smaller rank is the longer shared suffix. Two different keys at the same - rank are ambiguous. + if len(longer) >= len(shorter) and longer[-len(shorter) :] == shorter: + return len(longer) - len(shorter) + return None + + +def _suffix_rank(key_parts: tuple[str, ...], source: tuple[str, ...]) -> tuple[int, int] | None: + """Rank `(0, extra)` is a key that ends with the source. `(1, missing)` is the reverse. + + An equal path is rank `(0, 0)`. A shorter key matches only when the source + is strictly longer, so an equal path is not also a missing-part match. + """ + + if not source or not key_parts: + return None + extra = _as_suffix(key_parts, source) + if extra is not None: + return (0, extra) + if len(source) <= len(key_parts): + return None + missing = _as_suffix(source, key_parts) + if missing is None: + return None + return (1, missing) + + +def _rank(key_parts: tuple[str, ...], sources: list[tuple[str, ...]]) -> tuple[int, int] | None: + """The best suffix rank of one report key against the source paths. + + The smaller rank is the longer shared suffix. Two different keys at the + same rank are ambiguous. """ best: tuple[int, int] | None = None for source in sources: - if not source or not key_parts: - continue - if len(key_parts) >= len(source) and key_parts[-len(source) :] == source: - rank = (0, len(key_parts) - len(source)) - elif len(source) > len(key_parts) and source[-len(key_parts) :] == key_parts: - rank = (1, len(source) - len(key_parts)) - else: + rank = _suffix_rank(key_parts, source) + if rank is None: continue if best is None or rank < best: best = rank return best +def _source_parts(candidates: list[str]) -> list[tuple[str, ...]]: + sources: list[tuple[str, ...]] = [] + for candidate in candidates: + parts = _parts(candidate) + if parts and parts not in sources: + sources.append(parts) + return sources + + +def _keep_match(best, best_rank, key_parts, value, rank): + if best_rank is None or rank < best_rank: + return rank, [(key_parts, value)] + if rank == best_rank: + best.append((key_parts, value)) + return best_rank, best + + +def _best_value(normalized: dict, sources: list[tuple[str, ...]]): + best_rank: tuple[int, int] | None = None + best: list[tuple[tuple[str, ...], object]] = [] + for key, value in normalized.items(): + key_parts = _parts(key) + rank = _rank(key_parts, sources) + if rank is None: + continue + best_rank, best = _keep_match(best, best_rank, key_parts, value, rank) + if len({parts for parts, _value in best}) != 1: + return None + return best[0][1] + + def _lookup(index: dict, source_path: str): if not index: return None @@ -67,26 +119,7 @@ def _lookup(index: dict, source_path: str): found = _exact(normalized, candidates) if found is not None: return found - sources: list[tuple[str, ...]] = [] - for candidate in candidates: - parts = _parts(candidate) - if parts and parts not in sources: - sources.append(parts) - best_rank: tuple[int, int] | None = None - best: list[tuple[tuple[str, ...], object]] = [] - for key, value in normalized.items(): - key_parts = _parts(key) - rank = _rank(key_parts, sources) - if rank is None: - continue - if best_rank is None or rank < best_rank: - best_rank = rank - best = [(key_parts, value)] - elif rank == best_rank: - best.append((key_parts, value)) - if len({parts for parts, _value in best}) != 1: - return None - return best[0][1] + return _best_value(normalized, _source_parts(candidates)) def _covered_from_pairs(lines: dict[int, tuple[int, int]] | None) -> set[int] | None: diff --git a/src/mutator/edn.py b/src/mutator/edn.py index 9111fc4..6eab757 100644 --- a/src/mutator/edn.py +++ b/src/mutator/edn.py @@ -31,32 +31,65 @@ def keyword(name: str) -> tuple: return ("keyword", name) -def _render(value) -> str: - if value is None: - return "nil" - if isinstance(value, tuple) and len(value) == 2 and value[0] == "keyword": - return f":{value[1]}" - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int) and not isinstance(value, bool): - return str(value) +def _render_bool(value: bool) -> str: + return "true" if value else "false" + + +def _render_number(value) -> str: if isinstance(value, float): text = f"{value:.4f}".rstrip("0").rstrip(".") if "." not in text: text += ".0" return text + return str(value) + + +def _render_atom(value) -> str | None: + if value is None: + return "nil" + # bool is a subclass of int, so it has to be chosen first. + if isinstance(value, bool): + return _render_bool(value) + if isinstance(value, (int, float)): + return _render_number(value) if isinstance(value, str): return f'"{_escape(value)}"' + return None + + +def _render_keyword(value) -> str | None: + if isinstance(value, tuple) and len(value) == 2 and value[0] == "keyword": + return f":{value[1]}" + return None + + +def _render_sequence(value) -> str: + body = " ".join(_render(item) for item in value) + return f"[{body}]" if body else "[]" + + +def _render_key(key) -> str: + if isinstance(key, str) and _keyword_key(key): + return f":{key}" + return _render(key) + + +def _render_map(value: dict) -> str: + parts = [f"{_render_key(key)} {_render(item)}" for key, item in value.items()] + return "{" + " ".join(parts) + "}" + + +def _render(value) -> str: + atom = _render_atom(value) + if atom is not None: + return atom + rendered = _render_keyword(value) + if rendered is not None: + return rendered if isinstance(value, (list, tuple)): - body = " ".join(_render(item) for item in value) - return f"[{body}]" if body else "[]" + return _render_sequence(value) if isinstance(value, dict): - parts = [] - for key, item in value.items(): - rendered_key = f":{key}" if isinstance(key, str) and _keyword_key(key) else _render(key) - parts.append(f"{rendered_key} {_render(item)}") - body = " ".join(parts) - return "{" + body + "}" + return _render_map(value) raise TypeError(f"cannot render {type(value).__name__} as EDN") diff --git a/src/mutator/runner.py b/src/mutator/runner.py index 6cde33a..e554c76 100644 --- a/src/mutator/runner.py +++ b/src/mutator/runner.py @@ -177,6 +177,61 @@ def _python_command(directory: Path) -> list[str]: return [interpreter, "-m", "unittest", "discover"] +def _nearest_marker(source: Path, root: Path, markers: tuple[str, ...]) -> Path: + for marker in markers: + found = nearest(source, marker, root) + if found is not None: + return found + return root + + +def _go_package(directory: Path, source: Path) -> str: + relative = source.resolve().parent.relative_to(directory.resolve()) + if relative == Path("."): + return "." + return "./" + relative.as_posix() + + +def _clojure_plan(root: Path, source: Path) -> tuple[Command, Path]: + directory = _nearest_marker(source, root, ("deps.edn", "bb.edn")) + return _clojure_command(directory), directory + + +def _java_plan(root: Path, source: Path) -> tuple[Command, Path]: + directory = _nearest_marker(source, root, ("pom.xml",)) + return ["mvn", "-q", "test", "-DexcludeTags=no-mutate"], directory + + +def _go_plan(root: Path, source: Path) -> tuple[Command, Path]: + directory = _nearest_marker(source, root, ("go.mod",)) + return ["go", "test", "-count=1", _go_package(directory, source)], directory + + +def _typescript_plan(root: Path, source: Path) -> tuple[Command, Path]: + directory = _nearest_marker(source, root, ("package.json",)) + return ["npm", "test"], directory + + +def _rust_plan(root: Path, source: Path) -> tuple[Command, Path]: + directory = _nearest_marker(source, root, ("Cargo.toml",)) + return ["cargo", "test"], directory + + +def _python_plan(root: Path, source: Path) -> tuple[Command, Path]: + directory = _nearest_marker(source, root, ("pyproject.toml", "pytest.ini", "setup.cfg")) + return _python_command(directory), directory + + +_PLANS = { + "clojure": _clojure_plan, + "java": _java_plan, + "go": _go_plan, + "typescript": _typescript_plan, + "rust": _rust_plan, + "python": _python_plan, +} + + def test_plan(root: Path, source: Path, language: str, override: str | None) -> tuple[Command, Path]: """The test command and the directory it runs in. @@ -187,29 +242,7 @@ def test_plan(root: Path, source: Path, language: str, override: str | None) -> root = root.resolve() if override: return override, root - if language == "clojure": - directory = nearest(source, "deps.edn", root) or nearest(source, "bb.edn", root) or root - return _clojure_command(directory), directory - if language == "java": - directory = nearest(source, "pom.xml", root) or root - return ["mvn", "-q", "test", "-DexcludeTags=no-mutate"], directory - if language == "go": - directory = nearest(source, "go.mod", root) or root - relative = source.resolve().parent.relative_to(directory.resolve()) - package = "." if relative == Path(".") else "./" + relative.as_posix() - return ["go", "test", "-count=1", package], directory - if language == "typescript": - directory = nearest(source, "package.json", root) or root - return ["npm", "test"], directory - if language == "rust": - directory = nearest(source, "Cargo.toml", root) or root - return ["cargo", "test"], directory - if language == "python": - directory = ( - nearest(source, "pyproject.toml", root) - or nearest(source, "pytest.ini", root) - or nearest(source, "setup.cfg", root) - or root - ) - return _python_command(directory), directory - return ["false"], root + plan = _PLANS.get(language) + if plan is None: + return ["false"], root + return plan(root, source) diff --git a/src/mutator/sites.py b/src/mutator/sites.py index 26a1c11..d35635d 100644 --- a/src/mutator/sites.py +++ b/src/mutator/sites.py @@ -235,33 +235,76 @@ def _visit_clojure(node, data: bytes, found: list[RawSite]) -> None: stack.extend(reversed(current.children)) -def _visit_node(node, data: bytes, language: str, found: list[RawSite]) -> None: - parent = node.parent.type if node.parent is not None else "" - if node.child_count == 0: - token = _text(data, node) - # A literal sits inside the same expression as an operator, so these - # checks are independent. `0` is a child of `a > 0`, not an operator. - if parent in _BINARY_PARENTS: - mutant = _binary_mutant(language, token) - if mutant is not None: - _add(found, node, token, mutant, _category(token)) - if language == "typescript" and token == "?.": - optional = _optional_mutant(node) - if optional is not None: - _add(found, node, "?.", optional, "optional") - if token in {"!", "not"} and parent in _UNARY_PARENTS | {"comparison_operator"}: - _add(found, node, token, "", "unary") - if token == "-" and parent in _UNARY_PARENTS: - _add(found, node, token, "", "unary") - if node.type in {"true", "false"} and token in _BOOLEANS: - _add(found, node, token, _BOOLEANS[token], "boolean") - if node.type in _NUMBERS and token in {"0", "1"}: - _add(found, node, token, "1" if token == "0" else "0", "constant") - elif node.type == "bool_lit" and _text(data, node) in _BOOLEANS: - text = _text(data, node) +def _parent_type(node) -> str: + if node.parent is None: + return "" + return node.parent.type + + +def _visit_binary(node, token: str, parent: str, language: str, found: list[RawSite]) -> None: + if parent not in _BINARY_PARENTS: + return + mutant = _binary_mutant(language, token) + if mutant is not None: + _add(found, node, token, mutant, _category(token)) + + +def _visit_optional(node, token: str, language: str, found: list[RawSite]) -> None: + if language != "typescript" or token != "?.": + return + optional = _optional_mutant(node) + if optional is not None: + _add(found, node, "?.", optional, "optional") + + +def _visit_not(node, token: str, parent: str, found: list[RawSite]) -> None: + if token in {"!", "not"} and parent in _UNARY_PARENTS | {"comparison_operator"}: + _add(found, node, token, "", "unary") + + +def _visit_negation(node, token: str, parent: str, found: list[RawSite]) -> None: + if token == "-" and parent in _UNARY_PARENTS: + _add(found, node, token, "", "unary") + + +def _visit_boolean_token(node, token: str, found: list[RawSite]) -> None: + if node.type in {"true", "false"} and token in _BOOLEANS: + _add(found, node, token, _BOOLEANS[token], "boolean") + + +def _visit_constant(node, token: str, found: list[RawSite]) -> None: + if node.type in _NUMBERS and token in {"0", "1"}: + _add(found, node, token, _flip01(token), "constant") + + +def _visit_bool_lit(node, data: bytes, found: list[RawSite]) -> None: + if node.type != "bool_lit": + return + text = _text(data, node) + if text in _BOOLEANS: _add(found, node, text, _BOOLEANS[text], "boolean") +def _visit_leaf(node, data: bytes, language: str, found: list[RawSite]) -> None: + # A literal sits inside the same expression as an operator, so these + # checks are independent. `0` is a child of `a > 0`, not an operator. + token = _text(data, node) + parent = _parent_type(node) + _visit_binary(node, token, parent, language, found) + _visit_optional(node, token, language, found) + _visit_not(node, token, parent, found) + _visit_negation(node, token, parent, found) + _visit_boolean_token(node, token, found) + _visit_constant(node, token, found) + + +def _visit_node(node, data: bytes, language: str, found: list[RawSite]) -> None: + if node.child_count == 0: + _visit_leaf(node, data, language, found) + else: + _visit_bool_lit(node, data, found) + + def _visit(node, data: bytes, language: str, found: list[RawSite]) -> None: # An explicit stack: a 1,200-term expression must not raise RecursionError. stack = [node]