Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Rename or move is a new entry. There is no identity matching across runs.

Clojure follows crap4clj: `if` / `when` and their variants, `and`, `or`, `loop`, `catch`, and each clause of `cond`, `condp`, `case`, `cond->`, `cond->>`, `some->`, and `some->>`. Coverage prefers Cloverage's per-line form counts and falls back to LCOV.

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, from Maven, or from Gradle when the nearest build file is `build.gradle` or `build.gradle.kts`.

Go follows crap4go: `if`, `for`, `range`, each `switch` and `select` clause, and `&&` / `||`. Coverage is `go test -coverprofile`.

Expand All @@ -76,7 +76,7 @@ By default a run deletes the previous report for each language it is about to me
| Language | Command | Report |
| --- | --- | --- |
| Clojure | `clj -M:cov --lcov` (needs `deps.edn` or `bb.edn`) | `target/coverage/` |
| Java | JaCoCo Maven plugin `0.8.12` in each module with `pom.xml` | `target/site/jacoco/jacoco.xml` |
| Java | JaCoCo Maven plugin `0.8.12` in each module with `pom.xml`; otherwise `gradlew` or `gradle` with an init script that applies `jacoco`, then `test jacocoTestReport` | `target/site/jacoco/jacoco.xml` or `build/reports/jacoco/test/jacocoTestReport.xml` |
| Go | `go test ./... -coverprofile=...` | `target/coverage/go/coverage.out` |
| TypeScript | `npm run coverage`, or Vitest `--coverage`, or `npx c8 ... npm test` | `coverage/lcov.info` or `target/coverage/typescript/lcov.info` |
| Rust | `cargo llvm-cov` or `cargo tarpaulin`, per Cargo package | `target/coverage/rust/lcov.info` |
Expand Down
3 changes: 2 additions & 1 deletion src/crapper/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@

Coverage, when it is produced:
Clojure clj -M:cov --lcov, then Cloverage form counts or LCOV
Java Maven JaCoCo instruction coverage, for each pom.xml module
Java Maven or Gradle JaCoCo instruction coverage, for each pom.xml
or build.gradle module
Go go test ./... -coverprofile=...
TypeScript npm run coverage, Vitest's own LCOV, or c8 around npm test
Rust cargo llvm-cov, or cargo tarpaulin, in the nearest Cargo.toml
Expand Down
14 changes: 11 additions & 3 deletions src/crapper/coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,10 +532,18 @@ def _merge_go(root: Path) -> dict[str, list[tuple[int, int, int, int]]]:
return merged


_JACOCO_REPORTS = (
"target/site/jacoco/jacoco.xml",
"build/reports/jacoco/test/jacocoTestReport.xml",
)


def _merge_jacoco(root: Path) -> dict[str, list[JacocoMethod]]:
paths = [root / "target" / "site" / "jacoco" / "jacoco.xml"]
paths.extend(root.glob("*/target/site/jacoco/jacoco.xml"))
paths.extend(root.glob("*/*/target/site/jacoco/jacoco.xml"))
paths: list[Path] = []
for report in _JACOCO_REPORTS:
paths.append(root / report)
paths.extend(root.glob(f"*/{report}"))
paths.extend(root.glob(f"*/*/{report}"))
merged: dict[str, list[JacocoMethod]] = {}
for text in _merge_texts(paths):
for key, methods in parse_jacoco_index(text).items():
Expand Down
99 changes: 97 additions & 2 deletions src/crapper/runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

from crapper.discover import is_test_file, language_of
Expand All @@ -23,6 +24,22 @@
"org.jacoco:jacoco-maven-plugin:0.8.12:report",
]

_GRADLE_BUILD_FILES = ("build.gradle", "build.gradle.kts")
_GRADLE_SETTINGS_FILES = ("settings.gradle", "settings.gradle.kts")
_GRADLE_REPORT = Path("build/reports/jacoco/test/jacocoTestReport.xml")
# Applied from outside the build, like the Maven goals, so the project's
# build file needs no change.
_GRADLE_INIT = """\
allprojects {
apply plugin: 'jacoco'
tasks.withType(JacocoReport) {
reports {
xml.required = true
}
}
}
"""


def _warn(message: str) -> None:
print(message, file=sys.stderr)
Expand Down Expand Up @@ -427,6 +444,65 @@ def _modules_with(files: list[Path], suffix: str, marker: str, root: Path) -> li
return sorted(modules)


def _nearest_gradle(file: Path, root: Path) -> Path | None:
found = [_nearest(file, marker, root) for marker in _GRADLE_BUILD_FILES]
modules = [module for module in found if module is not None]
return max(modules, key=lambda module: len(module.parts), default=None)


def gradle_modules(root: Path, files: list[Path]) -> list[Path]:
"""Modules for Java files whose nearest build file is Gradle's.

A directory with both pom.xml and a Gradle build file is a Maven module.
"""

modules: set[Path] = set()
for file in files:
if Path(file).suffix != ".java":
continue
module = _nearest_gradle(file, root)
if module is None:
continue
maven = _nearest(file, "pom.xml", root)
if maven is None or len(module.parts) > len(maven.parts):
modules.add(module)
return [module for module in sorted(modules) if not _runs_inside(module, modules)]


def _gradle_build(module: Path) -> Path | None:
"""The directory whose settings file puts this module in a multi-project build."""

for directory in (module, *module.parents):
if any((directory / name).is_file() for name in _GRADLE_SETTINGS_FILES):
return directory
return None


def _runs_inside(module: Path, modules: set[Path]) -> bool:
"""True when a selected module above this one, in the same build, runs its tests.

Gradle runs a task in the current project and every project below it. A
settings file starts a separate build, which an outer run does not enter,
and without one a module is a build of its own.
"""

build = _gradle_build(module)
if build is None:
return False
return any(
outer != module and module.is_relative_to(outer) and outer.is_relative_to(build)
for outer in modules
)


def gradle_command(module: Path, root: Path, init_script: Path) -> list[str]:
"""The nearest Gradle wrapper, so a subproject uses its build's wrapper."""

wrapper = _nearest(module, "gradlew", root)
gradle = str(wrapper / "gradlew") if wrapper is not None else "gradle"
return [gradle, "--init-script", str(init_script), "test", "jacocoTestReport"]


def _cover_clojure(root: Path) -> None:
if not (root / "deps.edn").is_file() and not (root / "bb.edn").is_file():
_warn("No deps.edn or bb.edn; skipping Clojure coverage.")
Expand All @@ -442,9 +518,15 @@ def _cover_clojure(root: Path) -> None:

def _cover_java(root: Path, files: list[Path]) -> None:
modules = _modules_with(files, ".java", "pom.xml", root)
if not modules:
_warn("No pom.xml; skipping Java coverage.")
gradle = gradle_modules(root, files)
if not modules and not gradle:
_warn("No pom.xml or build.gradle; skipping Java coverage.")
return
_cover_maven(modules)
_cover_gradle(root, gradle)


def _cover_maven(modules: list[Path]) -> None:
for module in modules:
_clean_dir(module / "target" / "site" / "jacoco")
exec_file = module / "target" / "jacoco.exec"
Expand All @@ -455,6 +537,19 @@ def _cover_java(root: Path, files: list[Path]) -> None:
_warn(f"Java coverage exited {code} in {module}. Java coverage will score 0%.")


def _cover_gradle(root: Path, modules: list[Path]) -> None:
if not modules:
return
with tempfile.TemporaryDirectory() as scratch:
init_script = Path(scratch) / "jacoco.gradle"
init_script.write_text(_GRADLE_INIT, encoding="utf-8")
for module in modules:
(module / _GRADLE_REPORT).unlink(missing_ok=True)
code = run_shell(gradle_command(module, root, init_script), module)
if code != 0:
_warn(f"Java coverage exited {code} in {module}. Java coverage will score 0%.")


def _cover_go(root: Path, files: list[Path]) -> None:
modules = _modules_with(files, ".go", "go.mod", root)
if not modules:
Expand Down
19 changes: 19 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ def test_existing_coverage_feeds_the_snapshot(tmp_path):
assert ":crap 2.0" in text


def test_existing_gradle_jacoco_report_feeds_the_snapshot(tmp_path):
_write(tmp_path, "build.gradle", "")
_write(
tmp_path,
"src/main/java/demo/A.java",
"package demo;\nclass A { int f(int x){ if(x>0) return 1; if(x<0) return 2; return 0; } }\n",
)
_write(
tmp_path,
"build/reports/jacoco/test/jacocoTestReport.xml",
'<report><class name="demo/A"><method name="f" line="2">'
'<counter type="INSTRUCTION" missed="0" covered="9"/></method></class></report>',
)
assert run(["--root", str(tmp_path), "--use-existing-coverage"]) == 0
text = (tmp_path / ".metrics" / "crap.edn").read_text(encoding="utf-8")
assert ":coverage 100.0" in text
assert ":crap 3.0" in text


def test_threshold_exits_when_the_worst_score_is_too_high(tmp_path):
_write(tmp_path, "src/demo/core.clj", "(ns demo.core)\n\n(defn choose [x]\n (if x 1 0))\n")
lcov = tmp_path / "target" / "coverage" / "lcov.info"
Expand Down
20 changes: 20 additions & 0 deletions tests/test_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,23 @@ 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 test_load_bundle_reads_gradle_jacoco_reports_at_each_depth(tmp_path):
xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<!DOCTYPE report PUBLIC "-//JACOCO//DTD Report 1.1//EN" "report.dtd">'
'<report name="demo"><package name="demo"><class name="demo/{cls}">'
'<method name="f" desc="(I)I" line="2">'
'<counter type="INSTRUCTION" missed="1" covered="3"/></method>'
"</class></package></report>"
)
report = "build/reports/jacoco/test/jacocoTestReport.xml"
for prefix, cls in (("", "Root"), ("app/", "App"), ("libs/core/", "Core")):
path = tmp_path / f"{prefix}{report}"
path.parent.mkdir(parents=True)
path.write_text(xml.format(cls=cls), encoding="utf-8")
bundle = load_bundle(tmp_path)
for cls in ("Root", "App", "Core"):
found = function(language="java", name="f", namespace=f"demo.{cls}", start_line=2)
assert bundle.percent_for(found) == 75.0
Loading