Count multi-line statement sites as covered from their start line; skip TypeScript type positions

This commit is contained in:
Robert C. Martin
2026-10-21 14:33:45 -05:00
parent c57f03879a
commit a5a0cb803b
7 changed files with 109 additions and 3 deletions
+2 -2
View File
@@ -55,7 +55,7 @@ def _covered(site: Site, lines: set[int] | None, ignore_coverage: bool) -> bool:
return True
if lines is None:
return False
return site.line in lines
return site.line in lines or site.anchor_line in lines
def _select(
@@ -343,7 +343,7 @@ def scan_file(
found = [site for site in found if site.line in lines]
covered = None
if not ignore_coverage and covered_lines is not None:
covered = {site.mutation_id: site.line in covered_lines for site in found}
covered = _coverage_map(found, covered_lines, ignore_coverage)
history = load_history(root, file_key)
digests = form_digests(source, path, root)
changed = set()
+1
View File
@@ -171,6 +171,7 @@ def sites_in_file(source: str, path: Path, root: Path, file_key: str) -> list[Si
mutation_id=mutation_id(
file_key, owner.namespace, form, raw.start, raw.end, raw.original, raw.mutant
),
anchor_line=raw.anchor_line,
)
)
return found
+2
View File
@@ -20,6 +20,8 @@ class Site:
mutant: str
category: str
mutation_id: str
# The line its statement starts on; coverage on that line counts for the site.
anchor_line: int = 0
@property
def description(self) -> str:
+41 -1
View File
@@ -93,8 +93,12 @@ _CLOJURE_HEAD = {
class RawSite:
def __init__(self, line: int, start: int, end: int, original: str, mutant: str, category: str):
def __init__(
self, line: int, start: int, end: int, original: str, mutant: str, category: str, anchor_line: int = 0
):
self.line = line
# The line its statement starts on. LCOV marks only that line of a multi-line statement.
self.anchor_line = anchor_line or line
self.start = start
self.end = end
self.original = original
@@ -165,10 +169,44 @@ def _add(found: list[RawSite], node, original: str, mutant: str, category: str)
original=original,
mutant=mutant,
category=category,
anchor_line=_statement_line(node),
)
)
def _is_statement(node_type: str) -> bool:
return node_type.endswith("_statement") or node_type.endswith("_declaration")
def _statement_line(node) -> int:
current = node.parent
while current is not None:
if _is_statement(current.type):
return current.start_point[0] + 1
current = current.parent
return node.start_point[0] + 1
# A literal in a TypeScript type, such as `{ ok: true }` in a return type, never runs.
_TYPE_NODES = {
"type_annotation",
"type_alias_declaration",
"interface_declaration",
"type_arguments",
"type_parameters",
"literal_type",
}
def _in_type(node) -> bool:
current = node.parent
while current is not None:
if current.type in _TYPE_NODES:
return True
current = current.parent
return False
def _clojure_head(node, data: bytes) -> str | None:
for child in node.children:
if child.type == "sym_lit":
@@ -299,6 +337,8 @@ def _visit_leaf(node, data: bytes, language: str, found: list[RawSite]) -> None:
def _visit_node(node, data: bytes, language: str, found: list[RawSite]) -> None:
if language == "typescript" and _in_type(node):
return
if node.child_count == 0:
_visit_leaf(node, data, language, found)
else:
+9
View File
@@ -921,3 +921,12 @@ def test_snapshots_keep_other_files_empty_directories_and_odd_records(tmp_path):
assert not (base / "empty").exists()
assert (full / "keep.edn").is_file()
_remove_empty_dirs(tmp_path / "missing-metrics")
def test_scan_counts_a_site_inside_a_covered_multi_line_statement(tmp_path):
path = tmp_path / "src" / "demo.py"
path.parent.mkdir()
path.write_text("def add(a, b):\n return (a\n + b)\n", encoding="utf-8")
scanned = scan_file(path, tmp_path, covered_lines={2}, ignore_coverage=False, lines=None)
assert "uncovered" not in scanned
assert "+ -> -" in scanned
+23
View File
@@ -113,6 +113,29 @@ def test_uncovered_sites_are_not_executed(tmp_path):
assert result.forms[0].sites == 4
def test_a_site_inside_a_covered_multi_line_statement_is_run(tmp_path):
# LCOV marks only the line a statement starts on.
path = tmp_path / "src" / "demo.py"
path.parent.mkdir()
path.write_text("def add(a, b):\n return (a\n + b)\n", encoding="utf-8")
runner = FakeRunner()
result = mutate_file(
path,
tmp_path,
runner=runner,
covered_lines={2},
ignore_coverage=False,
mutate_all=True,
lines=None,
test_command="fake",
timeout_factor=10,
mutation_warning=50,
baselines={},
)
assert result.forms[0].uncovered == 0
assert result.forms[0].killed == 1
def test_a_failed_baseline_does_not_rewrite_metrics(tmp_path):
path = tmp_path / "src" / "demo.py"
path.parent.mkdir()
+31
View File
@@ -203,3 +203,34 @@ def test_a_deep_expression_is_walked(tmp_path):
assert discover_raw(python, "python", "app.py")
go = f"package p\nfunc place() int {{ return {expr} }}\n"
assert discover_raw(go, "go", "app.go")
def test_typescript_types_are_not_mutation_sites(tmp_path):
source = """type Flag = { on: true };
interface Box { open: false }
export function check(n: number): { ok: true } | { ok: false; used?: true } {
const seen: Promise<{ done: false }> | null = null;
return { ok: n > 0 };
}
"""
sites = _sites(tmp_path, "src/demo/check.ts", source)
described = {(site.line, site.original, site.mutant) for site in sites}
assert described == {(5, ">", ">="), (5, "0", "1")}
def test_a_site_remembers_where_its_statement_starts(tmp_path):
source = """export function save(db: Db, now: number) {
db
.prepare("x")
.bind(now + 1)
.run();
return {
ok: true,
};
}
"""
sites = _sites(tmp_path, "src/demo/save.ts", source)
anchors = {(site.line, site.original, site.anchor_line) for site in sites}
assert (4, "+", 2) in anchors
assert (4, "1", 2) in anchors
assert (7, "true", 6) in anchors