Skip to content
Closed
193 changes: 130 additions & 63 deletions codeflash/languages/java/gradle_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from typing import Any

from codeflash.languages.java.build_tool_strategy import BuildToolStrategy, module_to_dir
from codeflash.languages.java.build_tools import BuildTool, JavaProjectInfo
from codeflash.languages.java.build_tools import CODEFLASH_RUNTIME_JAR_NAME, BuildTool, JavaProjectInfo

_RE_INCLUDE = re.compile(r"""include\s*\(?([^)\n]+)\)?""")

Expand Down Expand Up @@ -78,6 +78,10 @@ def _get_skip_validation_init_script() -> str:
return _skip_validation_init_path


# JaCoCo version used for agent and CLI JARs.
_JACOCO_VERSION = "0.8.11"


# Cache for classpath strings — keyed on (gradle_root, test_module).
_classpath_cache: dict[tuple[Path, str | None], str] = {}

Expand All @@ -103,22 +107,6 @@ def cp = configurations.findByName('testRuntimeClasspath')
}
"""

# Gradle init script that applies JaCoCo plugin for coverage collection.
# Uses projectsEvaluated to avoid triggering configuration of unrelated subprojects.
_JACOCO_INIT_SCRIPT = """\
gradle.projectsEvaluated {
allprojects {
apply plugin: 'jacoco'
jacocoTestReport {
reports {
xml.required = true
html.required = false
}
}
}
}
"""


def find_gradle_build_file(project_root: Path) -> Path | None:
kts = project_root / "build.gradle.kts"
Expand Down Expand Up @@ -421,6 +409,10 @@ def find_executable(self, build_root: Path) -> str | None:

def ensure_runtime(self, build_root: Path, test_module: str | None) -> bool:
runtime_jar = self.find_runtime_jar()
if runtime_jar is None:
from codeflash.languages.java.maven_strategy import download_from_maven_central_http

runtime_jar = download_from_maven_central_http()
if runtime_jar is None:
logger.error("codeflash-runtime JAR not found. Generated tests will fail to compile.")
return False
Expand All @@ -432,7 +424,7 @@ def ensure_runtime(self, build_root: Path, test_module: str | None) -> bool:

libs_dir = module_root / "libs"
libs_dir.mkdir(parents=True, exist_ok=True)
dest_jar = libs_dir / "codeflash-runtime-1.0.1.jar"
dest_jar = libs_dir / CODEFLASH_RUNTIME_JAR_NAME

if not dest_jar.exists():
logger.info("Copying codeflash-runtime JAR to %s", dest_jar)
Expand Down Expand Up @@ -728,24 +720,17 @@ def run_tests_via_build_tool(
cmd = [gradle, task, "--no-daemon", "--rerun", "--init-script", init_path]
cmd.extend(["--init-script", _get_skip_validation_init_script()])

# --continue ensures Gradle keeps going even if some tests fail.
# For coverage: needed so jacocoTestReport runs even after test failures
# (matches Maven's -Dmaven.test.failure.ignore=true).
# Note: multi-module --tests filtering is handled by
# filter.failOnNoMatchingTests = false in the init script above
# (matches Maven's -DfailIfNoTests=false).
if enable_coverage:
cmd.append("--continue")

for class_filter in test_filter.split(","):
class_filter = class_filter.strip()
if class_filter:
cmd.extend(["--tests", class_filter])
logger.debug("Added --tests filters to Gradle command")

# Append jacocoTestReport AFTER --tests so Gradle doesn't try to apply --tests to it
if enable_coverage:
cmd.append("jacocoTestReport")
# JaCoCo coverage is collected via -javaagent (set up by run_tests_with_coverage),
# not via a Gradle task. No extra tasks or init scripts needed here.

logger.debug("Running Gradle command: %s in %s", " ".join(cmd), build_root)

Expand Down Expand Up @@ -885,7 +870,26 @@ def run_tests_with_coverage(
) -> tuple[subprocess.CompletedProcess[str], Path, Path | None]:
from codeflash.languages.java.test_runner import _get_combined_junit_xml

coverage_xml_path = self.setup_coverage(build_root, test_module, build_root)
# Determine coverage paths
if test_module:
module_path = build_root / module_to_dir(test_module)
else:
module_path = build_root
exec_path = module_path / "build" / "jacoco" / "test.exec"
exec_path.parent.mkdir(parents=True, exist_ok=True)
xml_path: Path | None = exec_path.with_suffix(".xml")

# Inject JaCoCo agent via JAVA_TOOL_OPTIONS — collects coverage during test execution
# without requiring any Gradle plugin or jacocoTestReport task.
try:
agent_jar = get_jacoco_agent_jar()
agent_opts = f"-javaagent:{agent_jar.absolute()}=destfile={exec_path.absolute()}"
existing = run_env.get("JAVA_TOOL_OPTIONS", "")
run_env["JAVA_TOOL_OPTIONS"] = f"{existing} {agent_opts}".strip() if existing else agent_opts
logger.info("JaCoCo agent enabled: %s", agent_opts)
except Exception:
logger.exception("Failed to configure JaCoCo agent — coverage will be unavailable")
xml_path = None

result = self.run_tests_via_build_tool(
build_root,
Expand All @@ -897,50 +901,31 @@ def run_tests_with_coverage(
test_module=test_module,
)

# Convert .exec → .xml using JaCoCo CLI (fast, ~2s even on large projects)
if xml_path and exec_path.exists():
classes_dirs = [
module_path / "build" / "classes" / "java" / "main",
module_path / "build" / "classes" / "java" / "test",
]
sources_dirs = [module_path / "src" / "main" / "java", module_path / "src" / "test" / "java"]
convert_jacoco_exec_to_xml(exec_path, classes_dirs, sources_dirs, xml_path)
elif xml_path:
logger.warning("JaCoCo .exec not found at %s — agent may not have run", exec_path)
xml_path = None

reports_dir = self.get_reports_dir(build_root, test_module)
result_xml_path = _get_combined_junit_xml(reports_dir, candidate_index)

return result, result_xml_path, coverage_xml_path
return result, result_xml_path, xml_path

def setup_coverage(self, build_root: Path, test_module: str | None, project_root: Path) -> Path | None:
# Coverage is collected via JaCoCo agent (injected in run_tests_with_coverage).
# Return the expected XML path so callers know where to look.
if test_module:
module_root = build_root / module_to_dir(test_module)
else:
module_root = project_root

build_file = find_gradle_build_file(module_root)
if build_file is None:
logger.warning("No build.gradle(.kts) found at %s, cannot setup JaCoCo", module_root)
return None

content = build_file.read_text(encoding="utf-8")
if "jacoco" not in content.lower():
logger.info("Adding JaCoCo plugin to %s for coverage collection", build_file.name)
is_kts = build_file.name.endswith(".kts")
if is_kts:
plugin_line = "plugins {\n jacoco\n}\n"
else:
plugin_line = "apply plugin: 'jacoco'\n"

if "plugins {" in content or "plugins{" in content:
# Insert jacoco inside existing plugins block
plugins_idx = content.find("plugins")
brace_depth = 0
for i in range(plugins_idx, len(content)):
if content[i] == "{":
brace_depth += 1
elif content[i] == "}":
brace_depth -= 1
if brace_depth == 0:
insert = " jacoco\n" if is_kts else " id 'jacoco'\n"
content = content[:i] + insert + content[i:]
break
else:
content = plugin_line + content

build_file.write_text(content, encoding="utf-8")

return module_root / "build" / "reports" / "jacoco" / "test" / "jacocoTestReport.xml"
return module_root / "build" / "jacoco" / "test.xml"

def get_test_run_command(self, project_root: Path, test_classes: list[str] | None = None) -> list[str]:
from codeflash.languages.java.test_runner import _validate_java_class_name
Expand All @@ -957,3 +942,85 @@ def get_test_run_command(self, project_root: Path, test_classes: list[str] | Non
for cls in test_classes:
cmd.extend(["--tests", cls])
return cmd


def get_jacoco_agent_jar(codeflash_home: Path | None = None) -> Path:
if codeflash_home is None:
codeflash_home = Path.home() / ".codeflash"

agent_dir = codeflash_home / "java_agents"
agent_dir.mkdir(parents=True, exist_ok=True)
agent_jar = agent_dir / "jacocoagent.jar"

if not agent_jar.exists():
import urllib.request

url = (
f"https://repo1.maven.org/maven2/org/jacoco/org.jacoco.agent/{_JACOCO_VERSION}/"
f"org.jacoco.agent-{_JACOCO_VERSION}-runtime.jar"
)
logger.info("Downloading JaCoCo agent from %s", url)
urllib.request.urlretrieve(url, agent_jar) # noqa: S310
logger.info("Downloaded JaCoCo agent to %s", agent_jar)

return agent_jar


def get_jacoco_cli_jar(codeflash_home: Path | None = None) -> Path:
if codeflash_home is None:
codeflash_home = Path.home() / ".codeflash"

cli_dir = codeflash_home / "java_agents"
cli_dir.mkdir(parents=True, exist_ok=True)
cli_jar = cli_dir / "jacococli.jar"

if not cli_jar.exists():
import urllib.request

url = (
f"https://repo1.maven.org/maven2/org/jacoco/org.jacoco.cli/{_JACOCO_VERSION}/"
f"org.jacoco.cli-{_JACOCO_VERSION}-nodeps.jar"
)
logger.info("Downloading JaCoCo CLI from %s", url)
urllib.request.urlretrieve(url, cli_jar) # noqa: S310
logger.info("Downloaded JaCoCo CLI to %s", cli_jar)

return cli_jar


def convert_jacoco_exec_to_xml(
exec_path: Path, classes_dirs: list[Path], sources_dirs: list[Path], xml_path: Path
) -> bool:
if not exec_path.exists():
logger.error("JaCoCo .exec file not found: %s", exec_path)
return False

try:
cli_jar = get_jacoco_cli_jar()
except Exception:
logger.exception("Failed to get JaCoCo CLI")
return False

cmd: list[str] = ["java", "-jar", str(cli_jar), "report", str(exec_path)]
for d in classes_dirs:
if d.exists():
cmd.extend(["--classfiles", str(d)])
for d in sources_dirs:
if d.exists():
cmd.extend(["--sourcefiles", str(d)])
cmd.extend(["--xml", str(xml_path)])

logger.info("Converting JaCoCo .exec to XML: %s", " ".join(cmd))
try:
result = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=60)
if result.returncode == 0:
logger.info("JaCoCo coverage XML generated: %s", xml_path)
return True
logger.error("Failed to convert .exec to XML: %s", result.stderr)
return False
except subprocess.TimeoutExpired:
logger.exception("JaCoCo CLI conversion timed out")
return False
except Exception:
logger.exception("Error converting .exec to XML")
return False
8 changes: 6 additions & 2 deletions codeflash/languages/java/line_profiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,8 @@ def find_method_for_line(
def find_agent_jar() -> Path | None:
"""Locate the profiler agent JAR file (now bundled in codeflash-runtime).

Checks local Maven repo, package resources, and development build directory.
Checks local Maven repo, package resources, development build directory,
and falls back to downloading from Maven Central via HTTP if not found locally.
"""
# Check local Maven repository first (fastest)
m2_jar = (
Expand All @@ -594,7 +595,10 @@ def find_agent_jar() -> Path | None:
if dev_jar.exists():
return dev_jar

return None
# Download from Maven Central as last resort (no mvn binary needed)
from codeflash.languages.java.maven_strategy import download_from_maven_central_http

return download_from_maven_central_http()


def resolve_internal_class_name(file_path: Path, source: str) -> str:
Expand Down
39 changes: 39 additions & 0 deletions codeflash/languages/java/maven_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,45 @@
</dependencies>"""


MAVEN_CENTRAL_URL = (
"https://repo1.maven.org/maven2/com/codeflash/codeflash-runtime"
f"/{CODEFLASH_RUNTIME_VERSION}/{CODEFLASH_RUNTIME_JAR_NAME}"
)

M2_JAR_PATH = (
Path.home()
/ ".m2"
/ "repository"
/ "com"
/ "codeflash"
/ "codeflash-runtime"
/ CODEFLASH_RUNTIME_VERSION
/ CODEFLASH_RUNTIME_JAR_NAME
)


def download_from_maven_central_http() -> Path | None:
"""Download codeflash-runtime JAR directly from Maven Central via HTTP.

No `mvn` binary required — works for Gradle-only users and the tracer flow.
Downloads to ~/.m2/repository/ so all resolution paths find it.
Returns the path to the JAR, or None if the download fails.
"""
if M2_JAR_PATH.exists():
return M2_JAR_PATH

try:
M2_JAR_PATH.parent.mkdir(parents=True, exist_ok=True)
logger.info("Downloading codeflash-runtime from Maven Central: %s", MAVEN_CENTRAL_URL)
urllib.request.urlretrieve(MAVEN_CENTRAL_URL, M2_JAR_PATH) # noqa: S310
logger.info("Downloaded codeflash-runtime to %s", M2_JAR_PATH)
return M2_JAR_PATH
except Exception as e:
logger.debug("Maven Central HTTP download failed: %s", e)
M2_JAR_PATH.unlink(missing_ok=True)
return None


def download_from_github_releases() -> Path | None:
"""Download codeflash-runtime JAR from GitHub Releases.

Expand Down
24 changes: 11 additions & 13 deletions codeflash/languages/java/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,13 @@ def _extract_modules_from_settings_gradle(content: str) -> list[str]:
Looks for include directives like:
include("module-a", "module-b") // Kotlin DSL
include 'module-a', 'module-b' // Groovy DSL
Handles multi-line include statements (comma-continued across lines).
Module names may be prefixed with ':' which is stripped.
"""
modules: list[str] = []
for match in re.findall(r"""include\s*\(?[^)\n]*\)?""", content):
# Match include blocks that may span multiple lines (comma-separated entries).
# The block ends at a line that doesn't end with a comma (after stripping comments/whitespace).
for match in re.findall(r"include\s*\(?((?:[^)\n]*,\s*\n)*[^)\n]*)\)?", content):
for name in re.findall(r"""['"]([^'"]+)['"]""", match):
modules.append(name.lstrip(":"))
return modules
Expand Down Expand Up @@ -420,11 +423,13 @@ def run_behavioral_tests(
if enable_coverage:
coverage_xml_path = strategy.setup_coverage(build_root, test_module, project_root)

min_timeout = 300 if enable_coverage else 60
# Coverage runs use the build tool (not direct JVM), so Gradle --no-daemon cold startup +
# compilation + test execution can take 10+ min on large multi-module projects.
min_timeout = 900 if enable_coverage else 60
effective_timeout = max(timeout or 300, min_timeout)

if enable_coverage:
# Coverage MUST use build tool JaCoCo runs as a plugin during the verify phase
# Coverage uses the build tool with JaCoCo agent injected via JAVA_TOOL_OPTIONS
result, result_xml_path, coverage_xml_path = strategy.run_tests_with_coverage(
build_root, test_module, test_paths, run_env, effective_timeout, candidate_index
)
Expand Down Expand Up @@ -452,20 +457,13 @@ def run_behavioral_tests(
)

if enable_coverage and coverage_xml_path:
target_dir = strategy.get_build_output_dir(build_root, test_module)
jacoco_exec_path = target_dir / "jacoco.exec"
logger.info("Coverage paths - target_dir: %s, coverage_xml_path: %s", target_dir, coverage_xml_path)
if jacoco_exec_path.exists():
logger.info("JaCoCo exec file exists: %s (%s bytes)", jacoco_exec_path, jacoco_exec_path.stat().st_size)
else:
logger.warning("JaCoCo exec file not found: %s - JaCoCo agent may not have run", jacoco_exec_path)
if coverage_xml_path.exists():
file_size = coverage_xml_path.stat().st_size
logger.info("JaCoCo XML report exists: %s (%s bytes)", coverage_xml_path, file_size)
logger.info("JaCoCo coverage XML: %s (%s bytes)", coverage_xml_path, file_size)
if file_size == 0:
logger.warning("JaCoCo XML report is empty - report generation may have failed")
logger.warning("JaCoCo XML report is empty report generation may have failed")
else:
logger.warning("JaCoCo XML report not found: %s - verify phase may not have completed", coverage_xml_path)
logger.warning("JaCoCo XML report not found: %s", coverage_xml_path)

return result_xml_path, result, coverage_xml_path, None

Expand Down
Loading
Loading