mirror of
https://github.com/unclebob/crapper.git
synced 2026-11-12 04:13:49 +00:00
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:
@@ -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
@@ -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+) '
|
r'<span[^>]*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]
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user