Find Go coverage by module import path.

Why: `go test -coverprofile` names each file by its module import path.
crapper found it only by path suffix, which works when the module path
happens to end with the project-relative path. A module in a subdirectory
whose name differs from its directory (`svc/go.mod` declaring
`module example.com/api`) scored 0% for every function.

What: `go_import_name` builds the profile's name for a file from the
nearest go.mod's module line and the path inside the module. The bundle
remembers its root and looks a Go file up by that name first, then by the
old suffix match. `_nearest` becomes the shared `nearest_marker` so
coverage and the runners find go.mod the same way. mutator uses
`go_import_name` so the two tools cannot drift.

Proof: three new coverage specs (nested module with a different name,
quoted module path, fallback without a module line) failed first, now
pass; 147 pass, the 2 bb/uml environment failures are unchanged.
crapper on changed code: max CRAP 9.0 (percent_for, unchanged), new
functions <= 7.0. dryer: no candidates in changed files. mutator on the
changed lines: 13/13 killed.
This commit is contained in:
Robert C. Martin
2026-11-10 12:38:04 -06:00
parent 6fd92c4c9b
commit c757ec79a6
4 changed files with 86 additions and 13 deletions
+1 -1
View File
@@ -61,7 +61,7 @@ Clojure follows crap4clj: `if` / `when` and their variants, `and`, `or`, `loop`,
Java follows crap4java: `if`, loops, `catch`, `?:`, each `switch` label, and `&&` / `||`. Constructors and methods inside anonymous classes are omitted. Coverage is JaCoCo's `INSTRUCTION` counter. Java follows crap4java: `if`, loops, `catch`, `?:`, each `switch` label, and `&&` / `||`. Constructors and methods inside anonymous classes are omitted. Coverage is JaCoCo's `INSTRUCTION` counter.
Go follows crap4go: `if`, `for`, `range`, each `switch` and `select` clause, and `&&` / `||`. Coverage is `go test -coverprofile`. Go follows crap4go: `if`, `for`, `range`, each `switch` and `select` clause, and `&&` / `||`. Coverage is `go test -coverprofile`, which names each file by its module import path (the `module` line of the nearest `go.mod` plus the path inside that module). crapper and mutator look a file up by that name first, then by the longest matching path suffix.
TypeScript, TSX, and JavaScript (`.js`, `.jsx`, `.mjs`, `.cjs`) use the same structural decisions as Java, including `&&` inside JSX, plus `??` and `?.`. Top-level functions, class methods, and top-level arrow functions are entries. An inline Express callback — `.get`, `.post`, `.put`, `.patch`, `.delete`, `.head`, `.options`, `.all`, `.use`, and `.route(path).get(...)` — is its own entry, named `GET /users`. A second callback on that same route is `GET /users#2`. Its decisions are not also charged to the enclosing function. Other nested callbacks stay inside the enclosing function. Coverage is LCOV. When the report has branch records (`BRDA`) inside a function, the score uses those branches; a function with no branches uses line hits. A `coverage` script is used as-is. A Vitest project runs `vitest --coverage` (installing `@vitest/coverage-v8` into `node_modules` when it is missing, without editing `package.json`). Other test scripts are wrapped in `c8`. TypeScript, TSX, and JavaScript (`.js`, `.jsx`, `.mjs`, `.cjs`) use the same structural decisions as Java, including `&&` inside JSX, plus `??` and `?.`. Top-level functions, class methods, and top-level arrow functions are entries. An inline Express callback — `.get`, `.post`, `.put`, `.patch`, `.delete`, `.head`, `.options`, `.all`, `.use`, and `.route(path).get(...)` — is its own entry, named `GET /users`. A second callback on that same route is `GET /users#2`. Its decisions are not also charged to the enclosing function. Other nested callbacks stay inside the enclosing function. Coverage is LCOV. When the report has branch records (`BRDA`) inside a function, the score uses those branches; a function with no branches uses line hits. A `coverage` script is used as-is. A Vitest project runs `vitest --coverage` (installing `@vitest/coverage-v8` into `node_modules` when it is missing, without editing `package.json`). Other test scripts are wrapped in `c8`.
+35 -8
View File
@@ -15,6 +15,7 @@ from pathlib import Path
from urllib.parse import unquote from urllib.parse import unquote
from crapper.model import Function from crapper.model import Function
from crapper.runners import nearest_marker
_SPAN = re.compile( _SPAN = re.compile(
r'<span[^>]*title="(\d+) out of (\d+) forms covered"[^>]*>\s*(\d+)&nbsp;' r'<span[^>]*title="(\d+) out of (\d+) forms covered"[^>]*>\s*(\d+)&nbsp;'
@@ -327,6 +328,21 @@ def _profile_segments(profile, path: str, source_parts: list[tuple[str, ...]] |
return profile[key] return profile[key]
_GO_MODULE = re.compile(r'^\s*module\s+"?([^"\s]+)"?', re.MULTILINE)
def go_import_name(file: Path, stop: Path) -> str | None:
"""The name `go test -coverprofile` gives a file: its module path plus its path in the module."""
module_dir = nearest_marker(file, "go.mod", stop)
if module_dir is None:
return None
found = _GO_MODULE.search(_read(module_dir / "go.mod") or "")
if found is None:
return None
return f"{found.group(1)}/{file.relative_to(module_dir).as_posix()}"
def go_percent( def go_percent(
profile: dict[str, list[tuple[int, int, int, int]]] | None, profile: dict[str, list[tuple[int, int, int, int]]] | None,
path: str, path: str,
@@ -334,7 +350,10 @@ def go_percent(
end: int, end: int,
source_parts: list[tuple[str, ...]] | None = None, source_parts: list[tuple[str, ...]] | None = None,
) -> float | None: ) -> float | None:
segments = _profile_segments(profile, path, source_parts) return _segments_percent(_profile_segments(profile, path, source_parts), start, end)
def _segments_percent(segments, start: int, end: int) -> float | None:
if segments is None: if segments is None:
return None return None
total = 0 total = 0
@@ -425,6 +444,7 @@ class CoverageBundle:
jacoco: dict[str, list[JacocoMethod]] | None = None jacoco: dict[str, list[JacocoMethod]] | None = None
form_html: dict[str, dict[int, tuple[int, int]]] = field(default_factory=dict) form_html: dict[str, dict[int, tuple[int, int]]] = field(default_factory=dict)
source_parts: list[tuple[str, ...]] = field(default_factory=list) source_parts: list[tuple[str, ...]] = field(default_factory=list)
root: Path | None = None
def bind_sources(self, paths: list[str]) -> None: def bind_sources(self, paths: list[str]) -> None:
"""Remember project files so a short path cannot take a longer file's report.""" """Remember project files so a short path cannot take a longer file's report."""
@@ -450,13 +470,8 @@ class CoverageBundle:
self.jacoco or {}, names, function.name, function.start_line self.jacoco or {}, names, function.name, function.start_line
) )
if function.language == "go": if function.language == "go":
return go_percent( segments = self._go_segments(function.path)
self.go_profile, return _segments_percent(segments, function.start_line, function.end_line)
function.path,
function.start_line,
function.end_line,
self.source_parts,
)
html = self._html_lines(function.path) html = self._html_lines(function.path)
if html is not None: if html is not None:
return percent_for_range(html, function.start_line, function.end_line) return percent_for_range(html, function.start_line, function.end_line)
@@ -468,6 +483,17 @@ class CoverageBundle:
return branched return branched
return percent_for_range(record, function.start_line, function.end_line) return percent_for_range(record, function.start_line, function.end_line)
def _go_segments(self, path: str) -> list[tuple[int, int, int, int]] | None:
"""Profile segments for one Go file: by import path, else by the longest path suffix."""
if self.go_profile is None:
return None
if self.root is not None:
name = go_import_name((self.root / path).resolve(), self.root)
if name in self.go_profile:
return self.go_profile[name]
return _profile_segments(self.go_profile, path, self.source_parts)
def _html_lines(self, path: str) -> dict[int, tuple[int, int]] | None: def _html_lines(self, path: str) -> dict[int, tuple[int, int]] | None:
return _lookup(self.form_html, path, self.source_parts) return _lookup(self.form_html, path, self.source_parts)
@@ -566,4 +592,5 @@ def load_bundle(root: Path) -> CoverageBundle:
go_profile=go_profile or None, go_profile=go_profile or None,
jacoco=jacoco or None, jacoco=jacoco or None,
form_html=_merge_forms(root), form_html=_merge_forms(root),
root=root,
) )
+4 -4
View File
@@ -54,7 +54,7 @@ def run_shell(command: str | list[str], cwd: Path) -> int:
return completed.returncode return completed.returncode
def _nearest(start: Path, marker: str, stop: Path) -> Path | None: def nearest_marker(start: Path, marker: str, stop: Path) -> Path | None:
current = start if start.is_dir() else start.parent current = start if start.is_dir() else start.parent
stop = stop.resolve() stop = stop.resolve()
while True: while True:
@@ -209,7 +209,7 @@ def typescript_packages(root: Path, files: list[Path]) -> list[Path]:
for file in files: for file in files:
if language_of(file) != "typescript": if language_of(file) != "typescript":
continue continue
package = _nearest(file, "package.json", root) package = nearest_marker(file, "package.json", root)
if package is not None: if package is not None:
packages.add(package.resolve()) packages.add(package.resolve())
return sorted(packages) return sorted(packages)
@@ -323,7 +323,7 @@ def python_roots(root: Path, files: list[Path]) -> list[Path]:
continue continue
module = None module = None
for marker in _PYTHON_MARKERS: for marker in _PYTHON_MARKERS:
module = _nearest(Path(file), marker, root) module = nearest_marker(Path(file), marker, root)
if module is not None: if module is not None:
break break
found.add((module or root).resolve()) found.add((module or root).resolve())
@@ -421,7 +421,7 @@ def _modules_with(files: list[Path], suffix: str, marker: str, root: Path) -> li
for file in files: for file in files:
if Path(file).suffix != suffix: if Path(file).suffix != suffix:
continue continue
module = _nearest(file, marker, root) module = nearest_marker(file, marker, root)
if module is not None: if module is not None:
modules.add(module) modules.add(module)
return sorted(modules) return sorted(modules)
+46
View File
@@ -158,3 +158,49 @@ def test_load_bundle_merges_each_report(tmp_path):
assert bundle.percent_for(function(start_line=3, end_line=4)) == 50.0 assert bundle.percent_for(function(start_line=3, end_line=4)) == 50.0
assert bundle.go_profile is not None assert bundle.go_profile is not None
assert bundle.jacoco is not None assert bundle.jacoco is not None
def _go_module(module_dir, module_line, profile_text):
module_dir.mkdir(parents=True, exist_ok=True)
(module_dir / "go.mod").write_text(f"{module_line}\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 test_go_coverage_names_a_nested_file_by_its_module_import_path(tmp_path):
# `go test` keys a profile by import path. The module is `example.com/api`
# but its directory is `svc`, so no suffix of `svc/calc/calc.go` names it.
_go_module(
tmp_path / "svc",
"module example.com/api // the service",
"mode: set\n"
"example.com/api/calc/calc.go:4.2,4.11 1 1\n"
"example.com/api/calc/calc.go:5.3,6.1 1 0\n",
)
bundle = load_bundle(tmp_path)
scored = function(language="go", path="svc/calc/calc.go", start_line=3, end_line=7)
assert bundle.percent_for(scored) == 50.0
def test_go_coverage_reads_a_quoted_module_path(tmp_path):
_go_module(
tmp_path / "cli",
'module "github.com/acme/tool"',
"mode: set\n"
"github.com/acme/tool/main.go:1.1,2.2 1 1\n"
"github.com/acme/tool/cmd/main.go:1.1,2.2 1 0\n",
)
bundle = load_bundle(tmp_path)
assert bundle.percent_for(function(language="go", path="cli/main.go", start_line=1, end_line=2)) == 100.0
assert bundle.percent_for(function(language="go", path="cli/cmd/main.go", start_line=1, end_line=2)) == 0.0
def test_go_coverage_falls_back_to_the_path_without_a_module_name(tmp_path):
profile = tmp_path / "target" / "coverage" / "go" / "coverage.out"
profile.parent.mkdir(parents=True)
profile.write_text("mode: set\nproj/board.go:1.1,2.2 1 1\n", encoding="utf-8")
scored = function(language="go", path="board.go", start_line=1, end_line=2)
assert load_bundle(tmp_path).percent_for(scored) == 100.0
(tmp_path / "go.mod").write_text("go 1.21\n", encoding="utf-8")
assert load_bundle(tmp_path).percent_for(scored) == 100.0