From c757ec79a6c15dc815238bbd9c2f4601eb54d3cc Mon Sep 17 00:00:00 2001 From: "Robert C. Martin" Date: Tue, 10 Nov 2026 12:38:04 -0600 Subject: [PATCH] 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. --- README.md | 2 +- src/crapper/coverage.py | 43 +++++++++++++++++++++++++++++++------- src/crapper/runners.py | 8 +++---- tests/test_coverage.py | 46 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f569041..56acc6d 100644 --- a/README.md +++ b/README.md @@ -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. -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`. diff --git a/src/crapper/coverage.py b/src/crapper/coverage.py index fea28fb..293b71d 100644 --- a/src/crapper/coverage.py +++ b/src/crapper/coverage.py @@ -15,6 +15,7 @@ from pathlib import Path from urllib.parse import unquote from crapper.model import Function +from crapper.runners import nearest_marker _SPAN = re.compile( r']*title="(\d+) out of (\d+) forms covered"[^>]*>\s*(\d+) ' @@ -327,6 +328,21 @@ def _profile_segments(profile, path: str, source_parts: list[tuple[str, ...]] | 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( profile: dict[str, list[tuple[int, int, int, int]]] | None, path: str, @@ -334,7 +350,10 @@ def go_percent( end: int, source_parts: list[tuple[str, ...]] | None = 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: return None total = 0 @@ -425,6 +444,7 @@ class CoverageBundle: jacoco: dict[str, list[JacocoMethod]] | None = None form_html: dict[str, dict[int, tuple[int, int]]] = field(default_factory=dict) source_parts: list[tuple[str, ...]] = field(default_factory=list) + root: Path | None = None def bind_sources(self, paths: list[str]) -> None: """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 ) if function.language == "go": - return go_percent( - self.go_profile, - function.path, - function.start_line, - function.end_line, - self.source_parts, - ) + segments = self._go_segments(function.path) + return _segments_percent(segments, function.start_line, function.end_line) html = self._html_lines(function.path) if html is not None: return percent_for_range(html, function.start_line, function.end_line) @@ -468,6 +483,17 @@ class CoverageBundle: return branched 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: 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, jacoco=jacoco or None, form_html=_merge_forms(root), + root=root, ) diff --git a/src/crapper/runners.py b/src/crapper/runners.py index 9b02fbc..fdf3c0d 100644 --- a/src/crapper/runners.py +++ b/src/crapper/runners.py @@ -54,7 +54,7 @@ def run_shell(command: str | list[str], cwd: Path) -> int: 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 stop = stop.resolve() while True: @@ -209,7 +209,7 @@ def typescript_packages(root: Path, files: list[Path]) -> list[Path]: for file in files: if language_of(file) != "typescript": continue - package = _nearest(file, "package.json", root) + package = nearest_marker(file, "package.json", root) if package is not None: packages.add(package.resolve()) return sorted(packages) @@ -323,7 +323,7 @@ def python_roots(root: Path, files: list[Path]) -> list[Path]: continue module = None for marker in _PYTHON_MARKERS: - module = _nearest(Path(file), marker, root) + module = nearest_marker(Path(file), marker, root) if module is not None: break 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: if Path(file).suffix != suffix: continue - module = _nearest(file, marker, root) + module = nearest_marker(file, marker, root) if module is not None: modules.add(module) return sorted(modules) diff --git a/tests/test_coverage.py b/tests/test_coverage.py index 36dcc75..4f85275 100644 --- a/tests/test_coverage.py +++ b/tests/test_coverage.py @@ -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.go_profile 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