diff --git a/src/mutator/coverage.py b/src/mutator/coverage.py index a4e5d33..ab082e8 100644 --- a/src/mutator/coverage.py +++ b/src/mutator/coverage.py @@ -1,6 +1,7 @@ """Decide which lines the tests already exercise. -Java uses JaCoCo source lines. Go uses statement profiles. Clojure prefers +Java uses JaCoCo source lines. Go uses statement profiles, which name a +file by its module import path, the way crapper names it. Clojure prefers Cloverage's per-line form counts, then LCOV. The other languages use LCOV. A file that does not appear in a report has no coverage data. """ @@ -109,13 +110,17 @@ def _best_value(normalized: dict, sources: list[tuple[str, ...]]): return best[0][1] -def _lookup(index: dict, source_path: str): +def _lookup(index: dict, source_path: str, name: str | None = None): + """The report entry for one source file. `name` is the report's own name for it.""" + if not index: return None crapper = ensure_crapper() normalize = crapper.coverage.normalize_path normalized = {normalize(key): value for key, value in index.items()} candidates = _candidates(normalize, source_path) + if name: + candidates.append(name) found = _exact(normalized, candidates) if found is not None: return found @@ -194,10 +199,12 @@ def _jacoco_index(root: Path) -> dict[str, set[int]]: return found -def _go_lines(profile, source_path: str) -> set[int] | None: +def _go_lines(root: Path, profile, source_path: str) -> set[int] | None: if not profile: return None - segments = _lookup(profile, source_path) + crapper = ensure_crapper() + name = crapper.coverage.go_import_name(Path(source_path), root.resolve()) + segments = _lookup(profile, source_path, name) if segments is None: return None covered: set[int] = set() @@ -218,7 +225,7 @@ def covered_lines(root: Path, source_path: Path, language: str) -> set[int] | No return _lookup(_jacoco_index(root), path) bundle = crapper.coverage.load_bundle(root) if language == "go": - return _go_lines(bundle.go_profile, path) + return _go_lines(root, bundle.go_profile, path) if language == "clojure": html = _covered_from_pairs(_lookup(bundle.form_html, path)) if html is not None: diff --git a/tests/test_coverage.py b/tests/test_coverage.py index c31eb4e..5f715ed 100644 --- a/tests/test_coverage.py +++ b/tests/test_coverage.py @@ -96,3 +96,43 @@ def test_go_profile_does_not_cross_files(tmp_path): profile.write_text("mode: set\na/b/widget.go:2.1,2.2 1 1\n", encoding="utf-8") assert covered_lines(tmp_path, short, "go") is None assert covered_lines(tmp_path, long, "go") == {2} + + +def _go_module(module_dir, module, profile_text): + module_dir.mkdir(parents=True, exist_ok=True) + (module_dir / "go.mod").write_text(f"module {module}\n\ngo 1.21\n", encoding="utf-8") + profile = module_dir / "target" / "coverage" / "go" / "coverage.out" + profile.parent.mkdir(parents=True) + profile.write_text(profile_text, encoding="utf-8") + + +def _go_source(path): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("package main\nfunc main() {}\n", encoding="utf-8") + return path + + +def test_go_profile_keyed_by_import_path_marks_hit_lines(tmp_path): + # `go test -coverprofile` names files by module import path, not by disk path. + _go_module( + tmp_path, + "github.com/acme/tool", + "mode: set\n" + "github.com/acme/tool/cmd/tool/main.go:2.1,3.2 1 1\n" + "github.com/acme/tool/cmd/tool/main.go:4.1,4.2 1 0\n" + "github.com/acme/tool/main.go:7.1,7.2 1 1\n", + ) + nested = _go_source(tmp_path / "cmd" / "tool" / "main.go") + top = _go_source(tmp_path / "main.go") + assert covered_lines(tmp_path, nested, "go") == {2, 3} + assert covered_lines(tmp_path, top, "go") == {7} + + +def test_go_profile_of_a_module_in_a_subdirectory(tmp_path): + _go_module( + tmp_path / "svc", + "example.com/api", + "mode: set\nexample.com/api/calc/calc.go:4.2,6.1 1 1\n", + ) + source = _go_source(tmp_path / "svc" / "calc" / "calc.go") + assert covered_lines(tmp_path, source, "go") == {4, 5, 6}