mirror of
https://github.com/unclebob/mutator.git
synced 2026-11-06 18:23:50 +00:00
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.
This commit is contained in:
+66
-33
@@ -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:
|
||||
|
||||
+50
-17
@@ -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")
|
||||
|
||||
|
||||
|
||||
+59
-26
@@ -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)
|
||||
|
||||
+67
-24
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user