| #!/usr/bin/env python3 |
| # *************************************************************************************************************************** |
| # * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file |
| # * distributed with this work for additional information regarding copyright ownership. The ASF licenses this file |
| # * to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance |
| # * with the License. You may obtain a copy of the License at |
| # * |
| # * http://www.apache.org/licenses/LICENSE-2.0 |
| # * |
| # * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an |
| # * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the |
| # * specific language governing permissions and limitations under the License. |
| # *************************************************************************************************************************** |
| """ |
| Coverage reporter for Apache Juneau. |
| |
| Shows JaCoCo branch and instruction coverage for a source file or folder. |
| |
| Coverage data is aggregated across ALL modules' unit tests (plus the |
| integration-test suite), not just juneau-integration-tests. JaCoCo writes a |
| separate target/jacoco.exec per Maven module, so a class exercised only by |
| unit tests in its own module (e.g. juneau-core/juneau-marshall) used to read |
| 0% because only the integration exec was consulted. This script discovers |
| every module's target/jacoco.exec and feeds them all -- together with each |
| owning module's target/classes -- to the JaCoCo CLI to produce one combined, |
| repo-wide report. |
| |
| Usage: |
| ./scripts/coverage.py <path> [options] |
| |
| Arguments: |
| path A source file (.java) or source folder to report on. |
| Paths can be absolute or relative to the repo root. |
| |
| Options: |
| --run, -r Re-run the owning module's tests before reporting |
| (refreshes that module's .exec; other modules' existing |
| execs are still merged in). |
| --branches, -b Show only lines with missed branches (default: show all uncovered). |
| --help, -h Show this help message. |
| |
| Examples: |
| ./scripts/coverage.py juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/conversion/ |
| ./scripts/coverage.py juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/BitSetFormat.java |
| ./scripts/coverage.py juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/BitSetFormat.java --run |
| """ |
| |
| import os |
| import re |
| import subprocess |
| import sys |
| import xml.etree.ElementTree as ET |
| from pathlib import Path |
| |
| REPO_ROOT = Path(__file__).resolve().parent.parent |
| |
| SRC_MARKERS = ["src/main/java", "src/test/java"] |
| |
| # Combined, repo-wide JaCoCo XML report assembled from every module's exec file. |
| # Lives under target/ (git-ignored); regenerated on every run. |
| COMBINED_XML = REPO_ROOT / "target" / "coverage" / "jacoco.xml" |
| |
| # Modules excluded from the combined report: |
| # - juneau-shaded / juneau-distrib repackage classes from other modules, which would |
| # introduce duplicate-class entries into the JaCoCo report. |
| # - examples / petstore / microservice / sc / test-utils are outside the coverage |
| # scope (mirrors <sonar.coverage.exclusions> in the root pom.xml). |
| EXCLUDED_MODULE_MARKERS = ( |
| "juneau-shaded", |
| "juneau-distrib", |
| "juneau-examples", |
| "juneau-petstore", |
| "juneau-microservice", |
| "juneau-sc", |
| "juneau-test-utils", |
| ) |
| |
| |
| def die(msg): |
| print(f"ERROR: {msg}", file=sys.stderr) |
| sys.exit(1) |
| |
| |
| def find_maven_module(path: Path) -> Path: |
| """Walk up from path to find the nearest directory containing a pom.xml.""" |
| p = path if path.is_dir() else path.parent |
| while p != REPO_ROOT.parent: |
| if (p / "pom.xml").exists() and p != REPO_ROOT: |
| return p |
| p = p.parent |
| return None |
| |
| |
| def path_to_jacoco_package(path: Path) -> tuple[str | None, str | None]: |
| """ |
| Given a source path, return (jacoco_package, filename). |
| jacoco_package uses slash separators (e.g. org/apache/juneau/commons/conversion). |
| filename is the .java filename, or None if path is a directory. |
| """ |
| parts = path.parts |
| for marker in SRC_MARKERS: |
| marker_parts = tuple(marker.split("/")) |
| for i in range(len(parts) - len(marker_parts) + 1): |
| if parts[i:i + len(marker_parts)] == marker_parts: |
| remainder = parts[i + len(marker_parts):] |
| if path.is_file(): |
| pkg = "/".join(remainder[:-1]) |
| fname = remainder[-1] |
| else: |
| pkg = "/".join(remainder).rstrip("/") |
| fname = None |
| return pkg, fname |
| return None, None |
| |
| |
| def discover_modules() -> list[Path]: |
| """Return all build modules (dirs with a pom.xml) excluding the reactor root and excluded markers.""" |
| poms = list(REPO_ROOT.glob("*/pom.xml")) + list(REPO_ROOT.glob("*/*/pom.xml")) |
| modules = [] |
| for pom in poms: |
| m = pom.parent |
| if m == REPO_ROOT: |
| continue |
| rel = str(m.relative_to(REPO_ROOT)) |
| if any(marker in rel for marker in EXCLUDED_MODULE_MARKERS): |
| continue |
| modules.append(m) |
| return sorted(modules) |
| |
| |
| def collect_jacoco_inputs() -> tuple[list[Path], list[Path], list[Path]]: |
| """ |
| Scan every active module and collect: |
| - exec files (target/jacoco.exec) -- execution data to merge |
| - class dirs (target/classes) -- bytecode to analyze |
| - source dirs (src/main/java) -- for source attribution |
| A module contributes its classes/sources whenever it has been compiled, and its |
| exec whenever its tests have run. Coverage of a class is the union of every exec |
| that touched it (its own module's unit tests + the integration suite + any other). |
| """ |
| execfiles, classdirs, srcdirs = [], [], [] |
| for m in discover_modules(): |
| classes = m / "target" / "classes" |
| execf = m / "target" / "jacoco.exec" |
| src = m / "src" / "main" / "java" |
| if classes.is_dir() and any(classes.rglob("*.class")): |
| classdirs.append(classes) |
| if src.is_dir(): |
| srcdirs.append(src) |
| if execf.is_file(): |
| execfiles.append(execf) |
| return execfiles, classdirs, srcdirs |
| |
| |
| def jacoco_version() -> str: |
| """Read <jacoco.plugin.version> from the root pom (fallback to a known-good default).""" |
| pom = (REPO_ROOT / "pom.xml").read_text(encoding="utf-8") |
| m = re.search(r"<jacoco\.plugin\.version>([^<]+)</jacoco\.plugin\.version>", pom) |
| return m.group(1).strip() if m else "0.8.14" |
| |
| |
| def maven_local_repo() -> Path: |
| """Best-effort resolution of the Maven local repository.""" |
| for env in ("MAVEN_REPO", "M2_REPO"): |
| v = os.environ.get(env) |
| if v and Path(v).is_dir(): |
| return Path(v) |
| return Path.home() / ".m2" / "repository" |
| |
| |
| def java_binary() -> str: |
| """Resolve a Java launcher, honoring JAVA_HOME, then ~/jdk/default, then PATH.""" |
| jh = os.environ.get("JAVA_HOME") |
| if jh and (Path(jh) / "bin" / "java").exists(): |
| return str(Path(jh) / "bin" / "java") |
| default_jdk = Path.home() / "jdk" / "default" / "bin" / "java" |
| if default_jdk.exists(): |
| return str(default_jdk) |
| return "java" |
| |
| |
| def jacoco_cli_jar() -> Path: |
| """Locate (resolving via Maven if needed) the JaCoCo CLI 'nodeps' jar.""" |
| ver = jacoco_version() |
| jar = maven_local_repo() / "org" / "jacoco" / "org.jacoco.cli" / ver / f"org.jacoco.cli-{ver}-nodeps.jar" |
| if jar.exists(): |
| return jar |
| print(f"Fetching JaCoCo CLI {ver}...") |
| result = subprocess.run( |
| ["mvn", "-q", "org.apache.maven.plugins:maven-dependency-plugin:3.6.1:get", |
| f"-Dartifact=org.jacoco:org.jacoco.cli:{ver}:jar:nodeps"], |
| cwd=REPO_ROOT, capture_output=True, text=True |
| ) |
| if result.returncode != 0 or not jar.exists(): |
| print(result.stderr[-2000:], file=sys.stderr) |
| die(f"Could not resolve JaCoCo CLI jar ({jar}).") |
| return jar |
| |
| |
| def run_tests(module: Path): |
| """Re-run a single module's tests to refresh its .exec file. |
| |
| Scoped to the owning module so single-file/class queries stay fast. Other |
| modules' existing execs are still merged in when the report is generated. |
| Requires upstream module artifacts to be available in the local repo (from a |
| prior full build, e.g. `mvn install -DskipTests`). |
| """ |
| rel = module.relative_to(REPO_ROOT) |
| print(f"Running tests for module {rel} to refresh coverage data...") |
| result = subprocess.run( |
| ["mvn", "-pl", str(rel), "test", "-Drat.skip=true", "-q"], |
| cwd=REPO_ROOT, |
| capture_output=True, |
| text=True |
| ) |
| if result.returncode != 0: |
| print(result.stderr[-3000:], file=sys.stderr) |
| die("Tests failed. Fix failures before checking coverage.") |
| print("Tests passed.\n") |
| |
| |
| def generate_combined_report() -> Path: |
| """Build the combined, repo-wide JaCoCo XML report from all module execs + classes.""" |
| execfiles, classdirs, srcdirs = collect_jacoco_inputs() |
| if not execfiles: |
| die("No jacoco.exec files found in any module. Run with --run, or build the " |
| "project first (e.g. `mvn -pl juneau-integration-tests -am test`).") |
| if not classdirs: |
| die("No compiled classes (target/classes) found. Build the project first.") |
| |
| COMBINED_XML.parent.mkdir(parents=True, exist_ok=True) |
| cmd = [java_binary(), "-jar", str(jacoco_cli_jar()), "report"] |
| cmd += [str(e) for e in execfiles] |
| for c in classdirs: |
| cmd += ["--classfiles", str(c)] |
| for s in srcdirs: |
| cmd += ["--sourcefiles", str(s)] |
| cmd += ["--xml", str(COMBINED_XML), "--name", "Juneau combined coverage", "--quiet"] |
| |
| print(f"Aggregating coverage from {len(execfiles)} module exec file(s)...") |
| result = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True) |
| if result.returncode != 0: |
| print(result.stderr[-3000:], file=sys.stderr) |
| die("Failed to generate combined JaCoCo report.") |
| return COMBINED_XML |
| |
| |
| def bar(covered, total, width=20): |
| """Render a simple ASCII progress bar.""" |
| if total == 0: |
| filled = width |
| else: |
| filled = round(covered / total * width) |
| return "[" + "#" * filled + "." * (width - filled) + "]" |
| |
| |
| def pct(covered, total): |
| if total == 0: |
| return "100%" |
| return f"{covered / total * 100:.0f}%" |
| |
| |
| def report(xml_path: Path, pkg_filter: str, file_filter: str | None, branches_only: bool): # NOSONAR python:S3776 -- Cognitive complexity is acceptable for XML report parsing and output formatting |
| """Parse jacoco.xml and print coverage for the matching package/file.""" |
| if not xml_path.exists(): |
| die(f"JaCoCo report not found at {xml_path}. Run with --run to generate it.") |
| |
| tree = ET.parse(xml_path) |
| root = tree.getroot() |
| |
| matched_packages = [] |
| for pkg in root.findall("package"): |
| name = pkg.get("name", "") |
| if pkg_filter and not (name == pkg_filter or name.startswith(pkg_filter + "/")): |
| continue |
| matched_packages.append(pkg) |
| |
| if not matched_packages: |
| die(f"No JaCoCo data found for package '{pkg_filter}'.\n" |
| "Make sure the module is built and the exec file is up to date (use --run).") |
| |
| # Collect per-file data |
| files_data = [] # list of (pkg_name, fname, lines_with_issues) |
| total_mb = total_cb = total_mi = total_ci = 0 |
| |
| for pkg in matched_packages: |
| pkg_name = pkg.get("name", "") |
| for sf in pkg.findall("sourcefile"): |
| fname = sf.get("name", "") |
| if file_filter and fname != file_filter: |
| continue |
| |
| # Aggregate counters from <counter> elements |
| mb = cb = mi = ci = 0 |
| for ctr in sf.findall("counter"): |
| t = ctr.get("type") |
| m = int(ctr.get("missed", 0)) |
| c = int(ctr.get("covered", 0)) |
| if t == "BRANCH": |
| mb, cb = m, c |
| elif t == "INSTRUCTION": |
| mi, ci = m, c |
| |
| total_mb += mb |
| total_cb += cb |
| total_mi += mi |
| total_ci += ci |
| |
| # Collect uncovered lines |
| uncovered = [] |
| for line in sf.findall("line"): |
| ln = int(line.get("nr", 0)) |
| lmb = int(line.get("mb", 0)) |
| lmi = int(line.get("mi", 0)) |
| if lmb > 0 or (not branches_only and lmi > 0): |
| lcb = int(line.get("cb", 0)) |
| uncovered.append((ln, lmb, lmb + lcb, lmi)) |
| |
| files_data.append((pkg_name, fname, mb, cb, mi, ci, uncovered)) |
| |
| if not files_data: |
| print("No data found for the specified path.") |
| return |
| |
| # Print per-file results |
| for pkg_name, fname, mb, cb, mi, ci, uncovered in sorted(files_data): |
| branch_total = mb + cb |
| instr_total = mi + ci |
| branch_pct = pct(cb, branch_total) |
| instr_pct = pct(ci, instr_total) |
| print(f"\n{'='*70}") |
| print(f" {pkg_name.replace('/', '.')}.{fname.removesuffix('.java')}") |
| print(f"{'='*70}") |
| print(f" Branches: {bar(cb, branch_total)} {branch_pct:>4} ({cb}/{branch_total} covered, {mb} missed)") |
| print(f" Instructions: {bar(ci, instr_total)} {instr_pct:>4} ({ci}/{instr_total} covered, {mi} missed)") |
| if uncovered: |
| print("\n Uncovered lines:") |
| for ln, lmb, ltotal, lmi in sorted(uncovered): |
| parts = [] |
| if lmb > 0: |
| parts.append(f"{lmb}/{ltotal} branches missed") |
| if lmi > 0: |
| parts.append(f"{lmi} instructions missed") |
| print(f" line {ln:4d}: {', '.join(parts)}") |
| else: |
| print("\n All lines covered!") |
| |
| # Print summary if multiple files |
| if len(files_data) > 1: |
| branch_total = total_mb + total_cb |
| instr_total = total_mi + total_ci |
| print(f"\n{'='*70}") |
| print(" TOTAL SUMMARY") |
| print(f"{'='*70}") |
| print(f" Branches: {bar(total_cb, branch_total)} {pct(total_cb, branch_total):>4} ({total_cb}/{branch_total} covered, {total_mb} missed)") |
| print(f" Instructions: {bar(total_ci, instr_total)} {pct(total_ci, instr_total):>4} ({total_ci}/{instr_total} covered, {total_mi} missed)") |
| print() |
| |
| |
| def main(): # NOSONAR: always returns 0 by design — standard POSIX exit code for success |
| args = sys.argv[1:] |
| if not args or "--help" in args or "-h" in args: |
| print(__doc__) |
| return 0 |
| |
| path_arg = None |
| do_run = False |
| branches_only = False |
| |
| for arg in args: |
| if arg in ("--run", "-r"): |
| do_run = True |
| elif arg in ("--branches", "-b"): |
| branches_only = True |
| elif arg.startswith("-"): |
| die(f"Unknown option: {arg}") |
| else: |
| path_arg = arg |
| |
| if not path_arg: |
| die("No path specified.") |
| |
| path = Path(path_arg) |
| if not path.is_absolute(): |
| path = REPO_ROOT / path |
| path = path.resolve() |
| |
| if not path.exists(): |
| die(f"Path does not exist: {path}") |
| |
| module = find_maven_module(path) |
| if not module: |
| die(f"Could not determine Maven module for path: {path}") |
| |
| pkg_filter, file_filter = path_to_jacoco_package(path) |
| if pkg_filter is None: |
| die(f"Path does not appear to be under src/main/java or src/test/java: {path}") |
| |
| if do_run: |
| run_tests(module) |
| |
| xml_path = generate_combined_report() |
| |
| report(xml_path, pkg_filter, file_filter, branches_only) |
| return 0 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |