Read Go coverage by module import path, the name go test gives a file.

Why: every Go run printed "No coverage data for <file>; its sites are
uncovered". `go test -coverprofile` keys files by import path
(`github.com/acme/tool/cmd/tool/main.go`); mutator looked them up by
absolute disk path (`/.../tool/cmd/tool/main.go`). Neither path ends with
the other, so no Go file ever matched and only --no-coverage ran sites.

What: `_go_lines` asks crapper's `go_import_name` for the profile's name
of the file (nearest go.mod module line plus path inside the module) and
`_lookup` tries that name as an exact key, then the existing suffix
match. Other languages pass no name and are unchanged. Needs crapper
c757ec7 or later.

Proof: two new specs (module at the root, module in a subdirectory with
a different name) failed with None first, now pass; 78 pass. crapper:
_go_lines 6.0, covered_lines 5.0, _lookup 4.0. dryer: no candidates.
mutator on the changed lines: one equivalent survivor (insert(0) vs
insert(1)) removed by appending the name; no survivors left.
This commit is contained in:
Robert C. Martin
2026-11-10 12:38:11 -06:00
parent a5a0cb803b
commit 830ec7e9c3
2 changed files with 52 additions and 5 deletions
+12 -5
View File
@@ -1,6 +1,7 @@
"""Decide which lines the tests already exercise. """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. 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. 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] 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: if not index:
return None return None
crapper = ensure_crapper() crapper = ensure_crapper()
normalize = crapper.coverage.normalize_path normalize = crapper.coverage.normalize_path
normalized = {normalize(key): value for key, value in index.items()} normalized = {normalize(key): value for key, value in index.items()}
candidates = _candidates(normalize, source_path) candidates = _candidates(normalize, source_path)
if name:
candidates.append(name)
found = _exact(normalized, candidates) found = _exact(normalized, candidates)
if found is not None: if found is not None:
return found return found
@@ -194,10 +199,12 @@ def _jacoco_index(root: Path) -> dict[str, set[int]]:
return found 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: if not profile:
return None 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: if segments is None:
return None return None
covered: set[int] = set() 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) return _lookup(_jacoco_index(root), path)
bundle = crapper.coverage.load_bundle(root) bundle = crapper.coverage.load_bundle(root)
if language == "go": if language == "go":
return _go_lines(bundle.go_profile, path) return _go_lines(root, bundle.go_profile, path)
if language == "clojure": if language == "clojure":
html = _covered_from_pairs(_lookup(bundle.form_html, path)) html = _covered_from_pairs(_lookup(bundle.form_html, path))
if html is not None: if html is not None:
+40
View File
@@ -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") 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, short, "go") is None
assert covered_lines(tmp_path, long, "go") == {2} 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}