mirror of
https://github.com/unclebob/mutator.git
synced 2026-11-07 10:43:49 +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)
|
return tuple(part for part in path.split("/") if part)
|
||||||
|
|
||||||
|
|
||||||
def _rank(key_parts: tuple[str, ...], sources: list[tuple[str, ...]]) -> tuple[int, int] | None:
|
def _as_suffix(longer: tuple[str, ...], shorter: tuple[str, ...]) -> int | None:
|
||||||
"""How well one report key names one of the source paths.
|
"""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.
|
if len(longer) >= len(shorter) and longer[-len(shorter) :] == shorter:
|
||||||
Rank `(1, missing)` is a shorter key that the source path ends with. The
|
return len(longer) - len(shorter)
|
||||||
smaller rank is the longer shared suffix. Two different keys at the same
|
return None
|
||||||
rank are ambiguous.
|
|
||||||
|
|
||||||
|
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
|
best: tuple[int, int] | None = None
|
||||||
for source in sources:
|
for source in sources:
|
||||||
if not source or not key_parts:
|
rank = _suffix_rank(key_parts, source)
|
||||||
continue
|
if rank is None:
|
||||||
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:
|
|
||||||
continue
|
continue
|
||||||
if best is None or rank < best:
|
if best is None or rank < best:
|
||||||
best = rank
|
best = rank
|
||||||
return best
|
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):
|
def _lookup(index: dict, source_path: str):
|
||||||
if not index:
|
if not index:
|
||||||
return None
|
return None
|
||||||
@@ -67,26 +119,7 @@ def _lookup(index: dict, source_path: str):
|
|||||||
found = _exact(normalized, candidates)
|
found = _exact(normalized, candidates)
|
||||||
if found is not None:
|
if found is not None:
|
||||||
return found
|
return found
|
||||||
sources: list[tuple[str, ...]] = []
|
return _best_value(normalized, _source_parts(candidates))
|
||||||
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]
|
|
||||||
|
|
||||||
|
|
||||||
def _covered_from_pairs(lines: dict[int, tuple[int, int]] | None) -> set[int] | None:
|
def _covered_from_pairs(lines: dict[int, tuple[int, int]] | None) -> set[int] | None:
|
||||||
|
|||||||
+48
-15
@@ -31,32 +31,65 @@ def keyword(name: str) -> tuple:
|
|||||||
return ("keyword", name)
|
return ("keyword", name)
|
||||||
|
|
||||||
|
|
||||||
def _render(value) -> str:
|
def _render_bool(value: bool) -> 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"
|
return "true" if value else "false"
|
||||||
if isinstance(value, int) and not isinstance(value, bool):
|
|
||||||
return str(value)
|
|
||||||
|
def _render_number(value) -> str:
|
||||||
if isinstance(value, float):
|
if isinstance(value, float):
|
||||||
text = f"{value:.4f}".rstrip("0").rstrip(".")
|
text = f"{value:.4f}".rstrip("0").rstrip(".")
|
||||||
if "." not in text:
|
if "." not in text:
|
||||||
text += ".0"
|
text += ".0"
|
||||||
return text
|
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):
|
if isinstance(value, str):
|
||||||
return f'"{_escape(value)}"'
|
return f'"{_escape(value)}"'
|
||||||
if isinstance(value, (list, tuple)):
|
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)
|
body = " ".join(_render(item) for item in value)
|
||||||
return f"[{body}]" if body else "[]"
|
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)):
|
||||||
|
return _render_sequence(value)
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
parts = []
|
return _render_map(value)
|
||||||
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 + "}"
|
|
||||||
raise TypeError(f"cannot render {type(value).__name__} as EDN")
|
raise TypeError(f"cannot render {type(value).__name__} as EDN")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+58
-25
@@ -177,6 +177,61 @@ def _python_command(directory: Path) -> list[str]:
|
|||||||
return [interpreter, "-m", "unittest", "discover"]
|
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]:
|
def test_plan(root: Path, source: Path, language: str, override: str | None) -> tuple[Command, Path]:
|
||||||
"""The test command and the directory it runs in.
|
"""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()
|
root = root.resolve()
|
||||||
if override:
|
if override:
|
||||||
return override, root
|
return override, root
|
||||||
if language == "clojure":
|
plan = _PLANS.get(language)
|
||||||
directory = nearest(source, "deps.edn", root) or nearest(source, "bb.edn", root) or root
|
if plan is None:
|
||||||
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
|
return ["false"], root
|
||||||
|
return plan(root, source)
|
||||||
|
|||||||
+53
-10
@@ -235,33 +235,76 @@ def _visit_clojure(node, data: bytes, found: list[RawSite]) -> None:
|
|||||||
stack.extend(reversed(current.children))
|
stack.extend(reversed(current.children))
|
||||||
|
|
||||||
|
|
||||||
def _visit_node(node, data: bytes, language: str, found: list[RawSite]) -> None:
|
def _parent_type(node) -> str:
|
||||||
parent = node.parent.type if node.parent is not None else ""
|
if node.parent is None:
|
||||||
if node.child_count == 0:
|
return ""
|
||||||
token = _text(data, node)
|
return node.parent.type
|
||||||
# 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:
|
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)
|
mutant = _binary_mutant(language, token)
|
||||||
if mutant is not None:
|
if mutant is not None:
|
||||||
_add(found, node, token, mutant, _category(token))
|
_add(found, node, token, mutant, _category(token))
|
||||||
if language == "typescript" and token == "?.":
|
|
||||||
|
|
||||||
|
def _visit_optional(node, token: str, language: str, found: list[RawSite]) -> None:
|
||||||
|
if language != "typescript" or token != "?.":
|
||||||
|
return
|
||||||
optional = _optional_mutant(node)
|
optional = _optional_mutant(node)
|
||||||
if optional is not None:
|
if optional is not None:
|
||||||
_add(found, node, "?.", optional, "optional")
|
_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"}:
|
if token in {"!", "not"} and parent in _UNARY_PARENTS | {"comparison_operator"}:
|
||||||
_add(found, node, token, "", "unary")
|
_add(found, node, token, "", "unary")
|
||||||
|
|
||||||
|
|
||||||
|
def _visit_negation(node, token: str, parent: str, found: list[RawSite]) -> None:
|
||||||
if token == "-" and parent in _UNARY_PARENTS:
|
if token == "-" and parent in _UNARY_PARENTS:
|
||||||
_add(found, node, token, "", "unary")
|
_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:
|
if node.type in {"true", "false"} and token in _BOOLEANS:
|
||||||
_add(found, node, token, _BOOLEANS[token], "boolean")
|
_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"}:
|
if node.type in _NUMBERS and token in {"0", "1"}:
|
||||||
_add(found, node, token, "1" if token == "0" else "0", "constant")
|
_add(found, node, token, _flip01(token), "constant")
|
||||||
elif node.type == "bool_lit" and _text(data, node) in _BOOLEANS:
|
|
||||||
|
|
||||||
|
def _visit_bool_lit(node, data: bytes, found: list[RawSite]) -> None:
|
||||||
|
if node.type != "bool_lit":
|
||||||
|
return
|
||||||
text = _text(data, node)
|
text = _text(data, node)
|
||||||
|
if text in _BOOLEANS:
|
||||||
_add(found, node, text, _BOOLEANS[text], "boolean")
|
_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:
|
def _visit(node, data: bytes, language: str, found: list[RawSite]) -> None:
|
||||||
# An explicit stack: a 1,200-term expression must not raise RecursionError.
|
# An explicit stack: a 1,200-term expression must not raise RecursionError.
|
||||||
stack = [node]
|
stack = [node]
|
||||||
|
|||||||
Reference in New Issue
Block a user