From a5a0cb803b66e0cdb8bf471b3b08af701d424a41 Mon Sep 17 00:00:00 2001 From: "Robert C. Martin" Date: Wed, 21 Oct 2026 14:33:45 -0500 Subject: [PATCH] Count multi-line statement sites as covered from their start line; skip TypeScript type positions --- src/mutator/engine.py | 4 ++-- src/mutator/functions.py | 1 + src/mutator/model.py | 2 ++ src/mutator/sites.py | 42 +++++++++++++++++++++++++++++++++++++++- tests/test_boundaries.py | 9 +++++++++ tests/test_engine.py | 23 ++++++++++++++++++++++ tests/test_sites.py | 31 +++++++++++++++++++++++++++++ 7 files changed, 109 insertions(+), 3 deletions(-) diff --git a/src/mutator/engine.py b/src/mutator/engine.py index e529424..0cc70d8 100644 --- a/src/mutator/engine.py +++ b/src/mutator/engine.py @@ -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() diff --git a/src/mutator/functions.py b/src/mutator/functions.py index 25d11ae..4d983b7 100644 --- a/src/mutator/functions.py +++ b/src/mutator/functions.py @@ -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 diff --git a/src/mutator/model.py b/src/mutator/model.py index 37f5745..293e788 100644 --- a/src/mutator/model.py +++ b/src/mutator/model.py @@ -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: diff --git a/src/mutator/sites.py b/src/mutator/sites.py index d35635d..50d1b78 100644 --- a/src/mutator/sites.py +++ b/src/mutator/sites.py @@ -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: diff --git a/tests/test_boundaries.py b/tests/test_boundaries.py index 080f29b..701cce9 100644 --- a/tests/test_boundaries.py +++ b/tests/test_boundaries.py @@ -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 diff --git a/tests/test_engine.py b/tests/test_engine.py index b24fc7c..1bdb2da 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -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() diff --git a/tests/test_sites.py b/tests/test_sites.py index 498b006..bae3e62 100644 --- a/tests/test_sites.py +++ b/tests/test_sites.py @@ -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