Extract helpers and test the behavior mutation testing was missing.

The switches stay intact. The new tests pin option parsing, file selection, normalization, and which forms each language compares, so the survivors that remain are equivalent mutants.
This commit is contained in:
Robert C. Martin
2026-09-30 17:25:34 -05:00
parent fcf865759d
commit 6892667b34
9 changed files with 875 additions and 108 deletions
+188 -2
View File
@@ -1,7 +1,9 @@
from pathlib import Path
from dryer.cli import main, parse_args, run
from dryer.discover import language_of
import pytest
from dryer.cli import _changed_files, _count, _tracked_source, main, parse_args, run, select_files
from dryer.discover import is_test_file, iter_source_files, language_of
def write_source(root: Path, name: str, text: str) -> None:
@@ -135,3 +137,187 @@ def test_main_exits(monkeypatch):
monkeypatch.setattr("dryer.cli.sys.exit", lambda code: codes.append(code))
main(["--help"])
assert codes == [0]
def test_names_that_are_tests():
names = [
"pkg/foo_test.go",
"src/app_test.clj",
"src/app_test.cljc",
"src/app_test.cljs",
"src/app_test.cljd",
"src/app_test.bb",
"ui/a.test.ts",
"ui/a.spec.ts",
"ui/a.test.tsx",
"ui/a.spec.tsx",
"ui/a.test.mts",
"ui/a.spec.mts",
"tests/conftest.py",
"pkg/foo_test.py",
"pkg/test_foo.py",
"tests/app.py",
]
for name in names:
assert is_test_file(name), name
assert not is_test_file("src/app.py")
class _Git:
def __init__(self, returncode, stdout, stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
def test_changed_files_reads_porcelain(tmp_path, monkeypatch):
stdout = ' M src/a.py\nR old.py -> src/b.py\n?? "src/c d.py"\n\nX\n'
monkeypatch.setattr(
"dryer.cli.subprocess.run",
lambda *args, **kwargs: _Git(0, stdout),
)
assert _changed_files(tmp_path) == [
(tmp_path / "src/a.py").resolve(),
(tmp_path / "src/b.py").resolve(),
(tmp_path / "src/c d.py").resolve(),
]
def test_changed_files_reports_git_failure(tmp_path, monkeypatch, capsys):
monkeypatch.setattr(
"dryer.cli.subprocess.run",
lambda *args, **kwargs: _Git(1, "", "fatal: not a git repository\n"),
)
assert _changed_files(tmp_path) == []
assert capsys.readouterr().err == "fatal: not a git repository\n"
monkeypatch.setattr(
"dryer.cli.subprocess.run",
lambda *args, **kwargs: _Git(1, "", " \n"),
)
assert _changed_files(tmp_path) == []
assert capsys.readouterr().err == "git status failed\n"
def test_changed_limits_the_report(tmp_path, monkeypatch, capsys):
body = "def alpha(xs):\n ys = filter(xs, odd)\n zs = map(ys, inc)\n return list(zs)\n"
other = "def beta(items):\n kept = filter(items, even)\n out = map(kept, dec)\n return list(out)\n"
write_source(tmp_path, "src/a.py", body)
write_source(tmp_path, "src/b.py", other)
write_source(tmp_path, "src/c.py", body)
write_source(tmp_path, "tests/test_a.py", other)
stdout = " M src/a.py\n M src/b.py\n M tests/test_a.py\n ?? README.md\n"
monkeypatch.setattr(
"dryer.cli.subprocess.run",
lambda *args, **kwargs: _Git(0, stdout),
)
assert run(["--root", str(tmp_path), "--changed", "--min-lines", "3", "--min-nodes", "1"]) == 0
out = capsys.readouterr().out
assert "src/a.py" in out
assert "src/b.py" in out
assert "src/c.py" not in out
assert "test_a.py" not in out
def test_an_option_without_a_value_is_a_usage_error(capsys):
assert run(["--threshold"]) == 1
assert "--threshold requires a value" in capsys.readouterr().err
def test_an_empty_value_is_rejected(capsys):
assert run(["file.py", "--min-lines", ""]) == 1
assert "--min-lines requires a value" in capsys.readouterr().err
def test_a_flag_is_not_a_value(capsys):
assert run(["--min-lines", "--min-nodes", "1"]) == 1
assert "--min-lines requires a value" in capsys.readouterr().err
def test_zero_is_a_valid_count():
options = parse_args(["--min-lines", "0", "--min-nodes", "0"])
assert options.action == "scan"
assert options.min_lines == 0
assert options.min_nodes == 0
def test_a_negative_count_is_rejected():
with pytest.raises(ValueError, match="non-negative"):
_count("-1", "--min-lines")
def test_a_count_must_be_an_integer(capsys):
assert run(["--min-lines", "nope"]) == 1
assert "--min-lines requires an integer" in capsys.readouterr().err
def test_a_threshold_must_be_a_number(capsys):
assert run(["--threshold", "nope"]) == 1
assert "--threshold requires a number" in capsys.readouterr().err
def test_parse_reads_the_process_arguments(monkeypatch):
monkeypatch.setattr("dryer.cli.sys.argv", ["dryer", "only-this"])
assert parse_args().positionals == ["only-this"]
def test_text_format_wins_when_it_comes_last(tmp_path, capsys):
write_source(tmp_path, "one.py", "def alpha(xs):\n return xs\n")
assert run(["--root", str(tmp_path), "--edn", "--text", "--min-lines", "1", "--min-nodes", "1"]) == 0
assert capsys.readouterr().out == "No duplicate candidates found.\n"
def test_source_root_limits_the_walk(tmp_path):
write_source(tmp_path, "src/a.py", "x = 1\n")
write_source(tmp_path, "extra/b.py", "x = 1\n")
options = parse_args(["--root", str(tmp_path), "--source-root", "src"])
files = [path.relative_to(tmp_path).as_posix() for path in select_files(options)]
assert files == ["src/a.py"]
def test_git_status_asks_for_text_without_checking(tmp_path, monkeypatch):
seen = {}
def fake_run(args, **kwargs):
seen["args"] = args
seen["kwargs"] = kwargs
return _Git(0, " M src/a.py\n")
monkeypatch.setattr("dryer.cli.subprocess.run", fake_run)
assert _changed_files(tmp_path) == [(tmp_path / "src/a.py").resolve()]
assert seen["args"] == ["git", "status", "--porcelain"]
assert seen["kwargs"]["check"] is False
assert seen["kwargs"]["capture_output"] is True
assert seen["kwargs"]["text"] is True
assert seen["kwargs"]["cwd"] == tmp_path
def test_a_status_line_of_four_characters_is_a_path(tmp_path, monkeypatch):
monkeypatch.setattr(
"dryer.cli.subprocess.run",
lambda *args, **kwargs: _Git(0, " M a\n"),
)
assert _changed_files(tmp_path) == [(tmp_path / "a").resolve()]
def test_changed_source_skips_unknown_and_test_files(tmp_path):
assert _tracked_source(tmp_path / "README.md") is False
assert _tracked_source(tmp_path / "tests" / "test_a.py") is False
assert _tracked_source(tmp_path / "src" / "a.py") is True
def test_test_files_and_skipped_directories_are_left_out(tmp_path):
write_source(tmp_path, "src/app.py", "x = 1\n")
write_source(tmp_path, "src/foo_test.py", "x = 1\n")
write_source(tmp_path, "target/app.py", "x = 1\n")
write_source(tmp_path, "tests/app.py", "x = 1\n")
relative = [path.relative_to(tmp_path).as_posix() for path in iter_source_files([tmp_path])]
assert relative == ["src/app.py"]
def test_a_second_report_replaces_the_snapshot(tmp_path, capsys):
write_source(tmp_path, "one.py", "def alpha(xs):\n return xs\n")
args = ["--root", str(tmp_path), "--min-lines", "1", "--min-nodes", "1"]
assert run(args) == 0
assert run(args) == 0
assert (tmp_path / ".metrics" / "dry.edn").read_text(encoding="utf-8") == "{:candidates []}\n"
+164 -2
View File
@@ -1,8 +1,10 @@
from pathlib import Path
from dryer.report import format_text
from dryer.scan import find_duplicates, scan_files
from dryer.clojure_read import Coll, Sym, is_candidate_form, normalize, read_source
from dryer.model import Duplicate, Span
from dryer.report import format_text, render_edn
from dryer.scan import find_duplicates, scan_files
from dryer.shape import K, node_count, pr
def write_source(root: Path, name: str, text: str) -> None:
@@ -23,6 +25,27 @@ def duplicates(root: Path, **options):
return find_duplicates(entries, options.get("threshold", 0.82))
def test_edn_report_matches_the_text_fields():
candidate = Duplicate(
0.875,
"clojure",
Span("a.clj", 10, 14),
Span("b.clj", 20, 24),
30,
31,
)
assert render_edn([candidate]) == (
"{:candidates [\n"
" {:score 0.875\n"
' :language "clojure"\n'
' :left {:file "a.clj", :start-line 10, :end-line 14}\n'
' :right {:file "b.clj", :start-line 20, :end-line 24}\n'
" :left-nodes 30\n"
" :right-nodes 31}\n"
"]}\n"
)
def test_text_report_matches_dry4clj():
candidate = Duplicate(
0.875,
@@ -185,3 +208,142 @@ def test_unreadable_tail_does_not_drop_an_earlier_form(tmp_path):
assert warnings == ["left.clj:6: unterminated list"]
found = find_duplicates(entries, 0.80)
assert len(found) == 1
def test_reads_escaped_strings_and_characters():
forms, warning = read_source('(defn alpha [x]\n (str "a\\\\nb" \\newline \\a))\n')
assert warning is None
assert [form.items[0].name for form in forms] == ["defn"]
def test_reports_a_broken_string_or_character():
_, warning = read_source('(defn alpha [x]\n "abc)\n')
assert warning == "2: unterminated string"
_, warning = read_source('(defn alpha [x] "abc\\')
assert warning == "1: unterminated string"
_, warning = read_source("(defn alpha [x] \\")
assert warning == "1: incomplete character"
def test_bom_and_shebang_are_not_forms():
forms, warning = read_source("\ufeff#!/usr/bin/env bb\n(defn alpha [x]\n (inc x))\n")
assert warning is None
assert [form.items[0].name for form in forms] == ["defn"]
def test_reader_conditional_keeps_clj_and_splices_a_collection():
forms, warning = read_source("#?(:cljs (inc x) :default (dec x))\n")
assert warning is None
assert forms[0].items[0].name == "dec"
forms, warning = read_source("#?((inc x) (dec x))\n")
assert warning is None
assert forms == []
forms, warning = read_source("#?[:clj (inc x)]\n")
assert warning == "1: reader conditional body must be a list"
forms, warning = read_source("(defn alpha []\n [#?@(:clj [:a :b])])\n")
assert warning is None
assert [item.name for item in forms[0].items[3].items] == ["a", "b"]
forms, warning = read_source("(defn alpha [x]\n (inc x))\n#?@(:clj {:a 1})\n")
assert warning == "3: splicing reader conditional needs a collection"
assert [form.items[0].name for form in forms] == ["defn"]
def test_empty_list_normalizes_to_a_literal_list():
forms, warning = read_source("(defn alpha [x]\n ())\n")
assert warning is None
assert "[:list :literal]" in pr(normalize(forms[0]))
def test_reader_macros_wrap_the_following_form():
forms, warning = read_source("'x\n@x\n`x\n~x\n~@x\n")
assert warning is None
assert [form.items[0].name for form in forms] == [
"quote",
"deref",
"syntax-quote",
"unquote",
"unquote-splicing",
]
assert forms[0].items[1].name == "x"
assert forms[4].items[1].name == "x"
forms, warning = read_source("'#_x\n")
assert warning is None
assert [item.name for item in forms[0].items] == ["quote"]
def test_a_character_name_is_consumed():
forms, warning = read_source("(defn alpha []\n (str \\newline))\n")
assert warning is None
assert [type(item).__name__ for item in forms[0].items[3].items] == ["Sym", "Lit"]
def test_a_bare_colon_is_an_empty_keyword():
forms, warning = read_source(":\n")
assert warning is None
assert forms[0].name == ""
forms, warning = read_source("#!only")
assert warning is None
assert forms == []
def test_namespaced_maps_keep_the_map():
for source in ("#:foo{:a 1}\n", "#::{:a 1}\n", "#:{:a 1}\n"):
forms, warning = read_source(source)
assert warning is None, source
assert forms[0].kind == "map"
assert forms[0].pairs[0][0].name == "a"
def test_map_pairs_keep_source_order():
forms, warning = read_source("{:a 1 :b 2}\n")
assert warning is None
assert [pair[0].name for pair in forms[0].pairs] == ["a", "b"]
left, warning = read_source("{foo 1}\n")
right, _ = read_source("{bar 2}\n")
assert warning is None
assert "foo" not in pr(normalize(left[0]))
assert pr(normalize(left[0])) == pr(normalize(right[0]))
paired, warning = read_source("{:a 1\n :b 2}\n")
assert warning is None
assert [value.line for _key, value in paired[0].pairs] == [1, 2]
symbols, warning = read_source("{foo bar}\n")
assert warning is None
printed = pr(normalize(symbols[0]))
assert "foo" not in printed
assert "bar" not in printed
def test_a_trailing_feature_without_an_expression_is_ignored():
forms, warning = read_source("#?(:clj (inc x) :extra)\n")
assert warning is None
assert forms[0].items[0].name == "inc"
forms, warning = read_source("#?(:cljs (dec x) :extra)\n")
assert warning is None
assert forms == []
def test_names_inside_a_set_are_erased():
forms, warning = read_source("#{foo bar}\n")
assert warning is None
printed = pr(normalize(forms[0]))
assert printed.startswith("[:set ")
assert "foo" not in printed
def test_only_a_non_empty_list_that_is_not_ns_is_a_candidate():
assert is_candidate_form(Coll("vector", 1, items=[Sym("a", 1)])) is False
assert is_candidate_form(Coll("list", 1, items=[])) is False
assert is_candidate_form(Sym("a", 1)) is False
assert is_candidate_form(Coll("list", 1, items=[Sym("ns", 1), Sym("demo", 1)])) is False
assert is_candidate_form(Coll("list", 1, items=[Sym("defn", 1)])) is True
assert pr(normalize(Coll("bag", 1))) == "[:literal]"
def test_node_count_and_keyword_printing():
assert repr(K("symbol")) == ":symbol"
assert node_count(K("symbol")) == 1
assert node_count([K("list"), K("symbol"), K("literal")]) == 4
+369 -1
View File
@@ -1,6 +1,9 @@
from pathlib import Path
from dryer.scan import find_duplicates, scan_files
from dryer.extract import entries_in_source
from dryer.model import Entry
from dryer.scan import _keep, find_duplicates, scan_files
from dryer.treesitter import descendants, end_line, parse
def write_source(root: Path, name: str, text: str) -> None:
@@ -231,3 +234,368 @@ class Right {
found = find_duplicates(entries, threshold=0.5)
assert len(found) == 1
assert found[0].score < 1.0
def _suffix(language: str) -> str:
return {"python": "py", "go": "go", "rust": "rs", "java": "java", "typescript": "ts"}[language]
def test_callee_names_stay_and_member_names_do_not(tmp_path):
samples = {
"left.py": """\
def alpha(xs):
ys = xs.filter(odd)
return (len)(xs.total)
""",
"right.py": """\
def beta(items):
kept = items.filter(even)
return (len)(items.count)
""",
"left.go": """\
package demo
func alpha(xs []int) {
fmt.Println(xs)
}
""",
"right.go": """\
package demo
func beta(items []int) {
log.Println(items)
}
""",
"left.rs": """\
pub fn alpha(xs: Vec<i32>) -> Vec<i32> {
let ys = Vec::new();
let zs = HashMap::<String, i32>::new();
xs.iter()
}
""",
"right.rs": """\
pub fn beta(items: Vec<i32>) -> Vec<i32> {
let kept = Vec::new();
let rows = HashMap::<Integer, i32>::new();
items.iter()
}
""",
"left.java": """\
class Left {
int alpha(List xs) {
List ys = new ArrayList<String>();
ys.add(xs);
return xs.total;
}
}
""",
"right.java": """\
class Right {
int beta(List items) {
List kept = new ArrayList<Integer>();
kept.add(items);
return items.count;
}
}
""",
"left.ts": """\
export function alpha(xs: number[]): number {
return foo<string>(xs.bar);
}
""",
"right.ts": """\
export function beta(items: number[]): number {
return foo<number>(items.baz);
}
""",
}
for name, source in samples.items():
write_source(tmp_path, name, source)
found = pairs(tmp_path)
by_language = {}
for item in found:
by_language.setdefault(item.language, []).append(item)
assert set(by_language) == {"python", "go", "rust", "java", "typescript"}
for language, group in by_language.items():
assert [(item.left.file, item.right.file, item.score) for item in group] == [
(f"left.{_suffix(language)}", f"right.{_suffix(language)}", 1.0)
], language
def test_a_different_callee_name_lowers_the_score(tmp_path):
write_source(tmp_path, "a.py", "def alpha(xs):\n return xs.filter(odd)\n")
write_source(tmp_path, "b.py", "def beta(items):\n return items.select(even)\n")
write_source(tmp_path, "a.rs", "fn alpha() {\n Vec::new()\n}\n")
write_source(tmp_path, "b.rs", "fn beta() {\n Vec::with_capacity(1)\n}\n")
files = sorted(path for path in tmp_path.rglob("*") if path.is_file())
entries, warnings = scan_files(files, tmp_path, min_lines=1, min_nodes=1)
assert warnings == []
found = find_duplicates(entries, threshold=0.0)
assert len(found) == 2
assert all(item.score < 1.0 for item in found)
def forms(language: str, source: str, file: str) -> list[Entry]:
entries, warning = entries_in_source(language, source, file, file)
assert warning is None
return entries
def spans(language: str, source: str, file: str) -> list[tuple[int, int]]:
return [(entry.start_line, entry.end_line) for entry in forms(language, source, file)]
def test_a_parenthesized_callee_matches_the_bare_call():
wrapped = forms("python", "def alpha(xs):\n return (len)(xs)\n", "a.py")
bare = forms("python", "def beta(items):\n return len(items)\n", "b.py")
assert any("len" in item for item in wrapped[0].fingerprints)
found = find_duplicates(wrapped + bare, threshold=0.99)
assert len(found) == 1
assert found[0].score == 1.0
def test_a_string_and_a_number_normalize_to_the_same_literal():
string = forms("python", 'def alpha(xs):\n return "xs"\n', "a.py")
number = forms("python", "def beta(items):\n return 1\n", "b.py")
assert any(item == ":literal" for item in number[0].fingerprints)
found = find_duplicates(string + number, threshold=0.99)
assert len(found) == 1
assert found[0].score == 1.0
def test_an_interpolated_string_is_not_a_plain_literal():
plain = forms("python", 'def alpha(xs):\n return "xs"\n', "a.py")
interpolated = forms("python", 'def beta(items):\n return f"{items}"\n', "b.py")
found = find_duplicates(plain + interpolated, threshold=0.0)
assert len(found) == 1
assert found[0].score < 1.0
def test_constructed_names_stay_in_the_fingerprint():
rust = forms(
"rust",
"""\
pub fn alpha() {
let ys = Vec::new();
let zs = HashMap::<String, i32>::new();
}
""",
"a.rs",
)
java = forms(
"java",
"""\
class Left {
int alpha() {
return new ArrayList<String>();
}
}
""",
"a.java",
)
rust_names = " ".join(rust[0].fingerprints)
java_names = " ".join(java[0].fingerprints)
assert "Vec" in rust_names
assert "new" in rust_names
assert "HashMap" in rust_names
assert "ArrayList" in java_names
def test_a_class_contributes_its_method_only():
assert spans(
"python",
"class Board:\n def alpha(self, xs):\n return xs\n",
"a.py",
) == [(2, 3)]
def test_java_keeps_the_method_that_has_its_own_body():
assert spans(
"java",
"""\
abstract class Left {
abstract int declared(int[] xs);
int alpha(int[] xs) {
class Inner {
int run(int[] ys) {
return ys.length;
}
}
return xs.length;
}
}
""",
"a.java",
) == [(3, 10)]
def test_a_go_function_needs_a_body():
assert spans(
"go",
"""\
package demo
func declared(xs []int)
func alpha(xs []int) int {
return len(xs)
}
""",
"a.go",
) == [(5, 7)]
def test_a_closure_in_a_let_stays_inside_the_function():
assert spans(
"rust",
"""\
fn alpha() {
let f = || { let x = 1; };
}
""",
"a.rs",
) == [(1, 3)]
def test_a_method_nested_in_a_function_is_not_its_own_candidate():
assert spans(
"typescript",
"""\
export function outer(xs: number[]): number[] {
class Hidden {
run(ys: number[]): number[] {
return ys;
}
}
return xs;
}
""",
"a.ts",
) == [(1, 8)]
def test_rust_keeps_helpers_and_skips_tests_and_signatures():
assert spans(
"rust",
"""\
fn declared();
fn alpha(xs: Vec<i32>) -> usize {
xs.len()
}
mod helpers {
fn beta(xs: Vec<i32>) -> usize {
xs.len()
}
}
mod tests {
fn gamma(xs: Vec<i32>) -> usize {
xs.len()
}
}
""",
"a.rs",
) == [(3, 5), (8, 10)]
def test_typescript_keeps_methods_and_skips_nested_functions():
assert spans(
"typescript",
"""\
export class Box {
run(xs: number[]): number[] {
return xs;
}
}
export function outer(xs: number[]): number[] {
function inner(ys: number[]): number[] {
return ys;
}
const cb = (zs: number[]) => zs;
return inner(xs);
}
xs.map((item: number) => item + 1);
""",
"a.ts",
) == [(2, 4), (6, 12)]
def test_a_private_field_name_is_not_part_of_the_structure():
entries = forms(
"typescript",
"""\
export class Box {
value(): number {
return this.#total;
}
run(xs: number[]): number[] {
return this.#hidden(xs);
}
}
""",
"a.ts",
)
assert [(entry.start_line, entry.end_line) for entry in entries] == [(2, 4), (5, 7)]
printed = " ".join(item for entry in entries for item in entry.fingerprints)
assert "#total" not in printed
assert "#hidden" not in printed
def test_end_line_uses_the_last_occupied_row():
source = "def alpha(xs):\n return xs\n"
_data, tree = parse(source, "python")
function = next(node for node in descendants(tree.root_node) if node.type == "function_definition")
assert end_line(tree.root_node) == 2
assert end_line(function) == 2
assert spans("python", source, "a.py") == [(1, 2)]
def _entry(file: str, start: int, end: int, names: set[str], language: str = "python", nodes: int = 20):
return Entry(language, file, start, end, nodes, frozenset(names))
def test_the_same_span_is_not_a_pair():
assert find_duplicates([
_entry("a.py", 1, 4, {"q"}),
_entry("a.py", 1, 4, {"q"}),
], 0.0) == []
assert len(find_duplicates([
_entry("a.py", 1, 4, {"q"}),
_entry("b.py", 1, 4, {"q"}),
], 0.5)) == 1
assert len(find_duplicates([
_entry("a.py", 1, 5, {"q"}),
_entry("a.py", 3, 5, {"q"}),
], 0.5)) == 1
assert len(find_duplicates([
_entry("a.py", 1, 4, {"q"}),
_entry("a.py", 1, 8, {"q"}),
], 0.5)) == 1
def test_a_score_equal_to_the_threshold_is_kept():
left = _entry("a.rs", 1, 4, {"x", "y"}, language="rust")
right = _entry("b.rs", 1, 4, {"x", "z"}, language="rust")
assert len(find_duplicates([left, right], 1 / 3)) == 1
assert find_duplicates([left, right], 1 / 3 + 0.01) == []
def test_the_higher_score_is_reported_first():
exact = [
_entry("a.py", 1, 4, {"only"}),
_entry("b.py", 1, 4, {"only"}),
]
partial = [
_entry("a.rs", 1, 4, {"x", "y"}, language="rust"),
_entry("b.rs", 10, 14, {"x", "z"}, language="rust"),
]
found = find_duplicates(partial + exact, 0.3)
assert [item.score for item in found] == [1.0, 1 / 3]
def test_keep_uses_the_line_and_node_minimums():
assert _keep(_entry("a.py", 1, 4, {"q"}, nodes=20), 4, 20) is True
assert _keep(_entry("a.py", 1, 3, {"q"}, nodes=20), 4, 20) is False
assert _keep(_entry("a.py", 1, 4, {"q"}, nodes=19), 4, 20) is False