fix: pre-install multi-module Maven deps to avoid recompilation failures

Multi-module Maven projects like Guava fail on sequential Maven invocations
because compiler plugin 3.15.0's JDK-8318913 workaround patches module-info.class
timestamps, triggering unnecessary recompilation with -am that fails on partial
reactor rebuilds. This pre-installs deps to .m2 once, then drops -am from all
subsequent test commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mohamed Ashraf 2026-03-04 00:37:32 +00:00
parent b9135c5f6a
commit 62aaab87ac
2 changed files with 188 additions and 6 deletions

View file

@ -43,6 +43,10 @@ logger = logging.getLogger(__name__)
# so we avoid calling `mvn dependency:build-classpath` (~2-3s) repeatedly.
_classpath_cache: dict[tuple[Path, str | None], str] = {}
# Cache for multi-module dependency installs — keyed on (maven_root, test_module).
# After pre-installing deps to .m2 once, subsequent Maven invocations can skip -am.
_multimodule_deps_installed: set[tuple[Path, str]] = set()
# Regex pattern for valid Java class names (package.ClassName format)
# Allows: letters, digits, underscores, dots, and dollar signs (inner classes)
_VALID_JAVA_CLASS_NAME = re.compile(r"^[a-zA-Z_$][a-zA-Z0-9_$.]*$")
@ -251,6 +255,68 @@ def _ensure_codeflash_runtime(maven_root: Path, test_module: str | None) -> bool
return True
def ensure_multi_module_deps_installed(maven_root: Path, test_module: str | None, env: dict[str, str]) -> bool:
"""Pre-install multi-module dependencies to the local Maven repository.
In multi-module Maven projects (like Guava), Maven compiler plugin 3.15.0's
JDK-8318913 workaround patches module-info.class timestamps after compilation.
When a subsequent Maven invocation uses -am (also-make), the compiler detects
"changed source code" and recompiles dependency modules which fails because
module-path resolution doesn't work in a partial reactor rebuild.
This function runs `mvn install -DskipTests -pl <module> -am` once to put all
dependency JARs into ~/.m2. After that, test-running commands can use
`-pl <module>` without `-am`, resolving deps from .m2 instead.
Skipped for single-module projects (test_module is None) and cached so it only
runs once per (maven_root, test_module) pair within a session.
"""
if not test_module:
return True
cache_key = (maven_root, test_module)
if cache_key in _multimodule_deps_installed:
logger.debug("Multi-module deps already installed for %s:%s, skipping", maven_root, test_module)
return True
mvn = find_maven_executable()
if not mvn:
logger.error("Maven not found — cannot pre-install multi-module dependencies")
return False
cmd = [
mvn,
"install",
"-DskipTests",
"-B",
"-pl",
test_module,
"-am",
]
cmd.extend(_MAVEN_VALIDATION_SKIP_FLAGS)
logger.info("Pre-installing multi-module dependencies: %s (module: %s)", maven_root, test_module)
logger.debug("Running: %s", " ".join(cmd))
try:
result = _run_cmd_kill_pg_on_timeout(cmd, cwd=maven_root, env=env, timeout=300)
if result.returncode != 0:
logger.error(
"Failed to pre-install multi-module deps (exit %d).\nstdout: %s\nstderr: %s",
result.returncode,
result.stdout[-2000:] if result.stdout else "",
result.stderr[-2000:] if result.stderr else "",
)
return False
except Exception:
logger.exception("Exception during multi-module dependency install")
return False
_multimodule_deps_installed.add(cache_key)
logger.info("Multi-module dependencies installed successfully for %s:%s", maven_root, test_module)
return True
def _extract_modules_from_pom_content(content: str) -> list[str]:
"""Extract module names from Maven POM XML content using proper XML parsing.
@ -485,6 +551,11 @@ def run_behavioral_tests(
# Ensure codeflash-runtime is installed and added as dependency before compilation
_ensure_codeflash_runtime(maven_root, test_module)
# Pre-install multi-module deps to .m2 so subsequent Maven runs don't need -am
base_env = os.environ.copy()
base_env.update(test_env)
ensure_multi_module_deps_installed(maven_root, test_module, base_env)
# Create SQLite database path for behavior capture - use standard path that parse_test_results expects
sqlite_db_path = get_run_tmp_file(Path(f"test_return_values_{candidate_index}.sqlite"))
@ -604,7 +675,7 @@ def _compile_tests(
cmd.extend(_MAVEN_VALIDATION_SKIP_FLAGS)
if test_module:
cmd.extend(["-pl", test_module, "-am"])
cmd.extend(["-pl", test_module])
logger.debug("Compiling tests: %s in %s", " ".join(cmd), project_root)
@ -1186,6 +1257,11 @@ def run_benchmarking_tests(
# Ensure codeflash-runtime is installed and added as dependency before compilation
_ensure_codeflash_runtime(maven_root, test_module)
# Pre-install multi-module deps to .m2 so subsequent Maven runs don't need -am
base_env = os.environ.copy()
base_env.update(test_env)
ensure_multi_module_deps_installed(maven_root, test_module, base_env)
# Get test class names
test_classes = _get_test_class_names(test_paths, mode="performance")
if not test_classes:
@ -1569,16 +1645,15 @@ def _run_maven_tests(
if enable_coverage:
cmd.append("-Dmaven.test.failure.ignore=true")
# For multi-module projects, specify which module to test
# For multi-module projects, specify which module to test.
# Dependencies are pre-installed to .m2 by ensure_multi_module_deps_installed(),
# so we use -pl without -am to avoid recompiling dependency modules (which fails
# on projects like Guava due to Maven compiler plugin's JDK-8318913 workaround).
if test_module:
# -am = also make dependencies
# -DfailIfNoTests=false allows dependency modules without tests to pass
# -DskipTests=false overrides any skipTests=true in pom.xml
cmd.extend(
[
"-pl",
test_module,
"-am",
"-DfailIfNoTests=false",
"-Dsurefire.failIfNoSpecifiedTests=false",
"-DskipTests=false",
@ -2019,6 +2094,11 @@ def run_line_profile_tests(
# Ensure codeflash-runtime is installed and added as dependency before compilation
_ensure_codeflash_runtime(maven_root, test_module)
# Pre-install multi-module deps to .m2 so subsequent Maven runs don't need -am
base_env = os.environ.copy()
base_env.update(test_env)
ensure_multi_module_deps_installed(maven_root, test_module, base_env)
# Set up environment with profiling mode
run_env = os.environ.copy()
run_env.update(test_env)

View file

@ -0,0 +1,102 @@
"""Tests for ensure_multi_module_deps_installed in Java test runner."""
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from codeflash.languages.java.test_runner import (
_multimodule_deps_installed,
ensure_multi_module_deps_installed,
)
@pytest.fixture(autouse=True)
def clear_cache():
"""Clear the multi-module deps cache before each test."""
_multimodule_deps_installed.clear()
yield
_multimodule_deps_installed.clear()
def test_skipped_for_single_module():
"""Single-module projects (test_module=None) should be a no-op."""
result = ensure_multi_module_deps_installed(Path("/fake"), None, {})
assert result is True
assert len(_multimodule_deps_installed) == 0
@patch("codeflash.languages.java.test_runner.find_maven_executable", return_value="mvn")
@patch("codeflash.languages.java.test_runner._run_cmd_kill_pg_on_timeout")
def test_runs_install_command_with_correct_args(mock_run, mock_mvn):
"""Should run mvn install -DskipTests -pl <module> -am with validation skip flags."""
mock_run.return_value = subprocess.CompletedProcess(args=["mvn"], returncode=0, stdout="", stderr="")
root = Path("/project")
result = ensure_multi_module_deps_installed(root, "guava-tests", {"JAVA_HOME": "/jdk"})
assert result is True
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert cmd[0] == "mvn"
assert "install" in cmd
assert "-DskipTests" in cmd
assert "-pl" in cmd
assert "guava-tests" in cmd
assert "-am" in cmd
assert "-B" in cmd
# Validation skip flags should be present
assert "-Drat.skip=true" in cmd
assert "-Dcheckstyle.skip=true" in cmd
# cwd should be maven_root
assert mock_run.call_args[1]["cwd"] == root
@patch("codeflash.languages.java.test_runner.find_maven_executable", return_value="mvn")
@patch("codeflash.languages.java.test_runner._run_cmd_kill_pg_on_timeout")
def test_caches_and_does_not_rerun(mock_run, mock_mvn):
"""Second call with same (root, module) should be cached — no Maven invocation."""
mock_run.return_value = subprocess.CompletedProcess(args=["mvn"], returncode=0, stdout="", stderr="")
root = Path("/project")
ensure_multi_module_deps_installed(root, "guava-tests", {})
assert mock_run.call_count == 1
# Second call — should be cached
result = ensure_multi_module_deps_installed(root, "guava-tests", {})
assert result is True
assert mock_run.call_count == 1 # NOT called again
@patch("codeflash.languages.java.test_runner.find_maven_executable", return_value="mvn")
@patch("codeflash.languages.java.test_runner._run_cmd_kill_pg_on_timeout")
def test_different_modules_not_cached(mock_run, mock_mvn):
"""Different test modules should each trigger their own install."""
mock_run.return_value = subprocess.CompletedProcess(args=["mvn"], returncode=0, stdout="", stderr="")
root = Path("/project")
ensure_multi_module_deps_installed(root, "module-a", {})
ensure_multi_module_deps_installed(root, "module-b", {})
assert mock_run.call_count == 2
@patch("codeflash.languages.java.test_runner.find_maven_executable", return_value="mvn")
@patch("codeflash.languages.java.test_runner._run_cmd_kill_pg_on_timeout")
def test_returns_false_on_maven_failure(mock_run, mock_mvn):
"""Non-zero exit code should return False and NOT cache."""
mock_run.return_value = subprocess.CompletedProcess(
args=["mvn"], returncode=1, stdout="", stderr="BUILD FAILURE"
)
root = Path("/project")
result = ensure_multi_module_deps_installed(root, "guava-tests", {})
assert result is False
assert len(_multimodule_deps_installed) == 0
@patch("codeflash.languages.java.test_runner.find_maven_executable", return_value=None)
def test_returns_false_when_maven_not_found(mock_mvn):
"""Should return False if Maven executable is not found."""
result = ensure_multi_module_deps_installed(Path("/fake"), "module", {})
assert result is False